Add image listing and asset management
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.
This commit is contained in:
1 parent
8b54ccba18
commit
d253536009
25 files changed
+801
-103
No files matched your search
+30
-9
@@ -4,7 +4,6 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
@@ -85,6 +84,7 @@ func (s *Server) routes() []route {
|
||||
{"POST /api/v1/import", authed, s.importNotes},
|
||||
{"POST /api/v1/assets", authed, s.uploadAsset},
|
||||
{"POST /api/v1/assets/import", authed, s.importAsset},
|
||||
{"PUT /api/v1/assets/{path...}", authed, s.replaceImage},
|
||||
{"GET /api/v1/raw/{path...}", authed, s.raw},
|
||||
{"GET /api/v1/trash", authed, s.trashList},
|
||||
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
|
||||
@@ -352,18 +352,13 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
rewritten, err := s.vault.Move(body.From, body.To)
|
||||
rewritten, err := s.vault.Move(body.From, body.To, func() error {
|
||||
return s.store.MoveShares(body.From, body.To)
|
||||
})
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.MoveShares(body.From, body.To); err != nil {
|
||||
if _, rollback := s.vault.Move(body.To, body.From); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
|
||||
}
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if rewritten {
|
||||
// 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。
|
||||
s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"})
|
||||
@@ -400,6 +395,32 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]string{"link": link})
|
||||
}
|
||||
|
||||
// replaceImage 仅替换已存在的图片;路径与引用保持不变。
|
||||
func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeError(w, 413, "图片不能超过 50 MB")
|
||||
} else {
|
||||
writeError(w, 400, "无法读取上传文件")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, 400, "缺少上传文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := s.vault.ReplaceImage(r.PathValue("path"), header.Filename, file, maxAssetBytes); err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
|
||||
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveRaw(w, r, r.PathValue("path"))
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplaceImageAPI(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("assets/a.png", "original")
|
||||
f.write("a.md", "")
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
file, err := form.CreateFormFile("file", "replacement.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(file, "replacement"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := form.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := map[string]string{"Content-Type": form.FormDataContentType()}
|
||||
if code := f.status(newClient(), "PUT", "/api/v1/assets/assets/a.png", body.String(), headers); code != 401 {
|
||||
t.Fatalf("未登录应被拒绝:%d", code)
|
||||
}
|
||||
readToken := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"read","scope":"read"}`, nil)).Token
|
||||
if code := f.status(newClient(), "PUT", "/api/v1/assets/assets/a.png", body.String(), map[string]string{"Content-Type": form.FormDataContentType(), "Authorization": "Bearer " + readToken}); code != 403 {
|
||||
t.Fatalf("只读令牌不能替换图片:%d", code)
|
||||
}
|
||||
for route, want := range map[string]int{"/api/v1/assets/assets/a.png": 200, "/api/v1/assets/missing.png": 404, "/api/v1/assets/a.md": 400} {
|
||||
if code := f.status(f.admin, "PUT", route, body.String(), headers); code != want {
|
||||
t.Fatalf("%s:得到 %d,期望 %d", route, code, want)
|
||||
}
|
||||
}
|
||||
res := f.do(f.admin, "GET", "/api/v1/raw/assets/a.png", "", nil)
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil || string(data) != "replacement" || res.Header.Get("Content-Security-Policy") == "" || res.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("替换内容或安全边界错误:%q %v", data, err)
|
||||
}
|
||||
note := f.do(f.admin, "GET", "/api/v1/files/a.md", "", nil)
|
||||
data, err = io.ReadAll(note.Body)
|
||||
if err != nil || string(data) != "" {
|
||||
t.Fatalf("替换不应改变引用:%q %v", data, err)
|
||||
}
|
||||
// 复用现有移动与回收站接口,图片不会被当作 Markdown 正文读取。
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"assets/a.png","to":"assets/b.png"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
deleted := decode[struct{ ID string }](t, f.do(f.admin, "DELETE", "/api/v1/files/assets/b.png", "", nil))
|
||||
if code := f.status(f.admin, "POST", "/api/v1/trash/"+deleted.ID+"/restore", "", nil); code != http.StatusOK {
|
||||
t.Fatal(code)
|
||||
}
|
||||
}
|
||||
@@ -398,7 +398,7 @@
|
||||
},
|
||||
"/tree": {
|
||||
"get": {
|
||||
"summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)",
|
||||
"summary": "全部笔记、图片与文件夹(含 .assets,不含隐藏条目或符号链接)",
|
||||
"tags": [
|
||||
"笔记"
|
||||
],
|
||||
@@ -583,7 +583,7 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "把笔记或文件夹移入回收站(连同 .assets)",
|
||||
"summary": "把笔记、图片或文件夹移入回收站(笔记连同 .assets)",
|
||||
"tags": [
|
||||
"笔记"
|
||||
],
|
||||
@@ -613,11 +613,11 @@
|
||||
},
|
||||
"/move": {
|
||||
"post": {
|
||||
"summary": "移动或重命名笔记/文件夹",
|
||||
"summary": "移动或重命名笔记、图片或文件夹",
|
||||
"tags": [
|
||||
"笔记"
|
||||
],
|
||||
"description": "笔记的 .assets 目录随之移动,改名时同步改写正文中指向自身图片目录的链接;分享链接随之更新。不改写其他笔记指向它的链接。",
|
||||
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。分享链接随主体更新。不改写其他笔记指向笔记或文件夹的链接。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -2273,6 +2273,59 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/assets/{path}": {
|
||||
"put": {
|
||||
"summary": "原位替换已有图片,保留路径和引用",
|
||||
"tags": [
|
||||
"图片"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "仓库中的图片路径",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "multipart file 字段的扩展名须与目标图片一致;不创建新文件,最大 50 MB。旧图片不会进入回收站。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "替换成功"
|
||||
},
|
||||
"400": {
|
||||
"description": "非法路径、空文件或扩展名不一致"
|
||||
},
|
||||
"404": {
|
||||
"description": "目标图片不存在"
|
||||
},
|
||||
"413": {
|
||||
"description": "超过 50 MB"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。
|
||||
func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error {
|
||||
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
|
||||
return fmt.Errorf("%w:请选择与原图片扩展名相同的文件", ErrInvalidPath)
|
||||
}
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return ErrTooLarge
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("%w:图片不能为空", ErrInvalidPath)
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
info, err := os.Lstat(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return ErrInvalidPath
|
||||
}
|
||||
return atomicWrite(p, data)
|
||||
}
|
||||
|
||||
type imageLinkChange struct {
|
||||
path string
|
||||
before, after []byte
|
||||
}
|
||||
|
||||
// 调用方持有写锁;所有原始内容留到移动成功,以便复合操作失败时回滚。
|
||||
func (v *Vault) imageLinkChanges(from, to string) ([]imageLinkChange, error) {
|
||||
entries, err := v.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []imageLinkChange
|
||||
for _, e := range entries {
|
||||
if e.Dir || !IsNote(e.Path) {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(v.root, filepath.FromSlash(e.Path))
|
||||
before, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := rewriteImageLinks(before, e.Path, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
changes = append(changes, imageLinkChange{p, before, after})
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) {
|
||||
rel, err := filepath.Rel(filepath.FromSlash(path.Dir(note)), filepath.FromSlash(to))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transform := func(text []byte) []byte {
|
||||
spans := importLinkSpans(text)
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
||||
var out []byte
|
||||
last := 0
|
||||
for _, span := range spans {
|
||||
if span.start < last {
|
||||
continue
|
||||
}
|
||||
target := string(text[span.start:span.end])
|
||||
if span.html {
|
||||
target = html.UnescapeString(target)
|
||||
} else {
|
||||
target = markdownEscape.ReplaceAllString(target, "$1")
|
||||
}
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" || path.Clean(path.Join(path.Dir(note), u.Path)) != from {
|
||||
continue
|
||||
}
|
||||
replacement := EscapeLinkPath(filepath.ToSlash(rel))
|
||||
if u.RawQuery != "" {
|
||||
replacement += "?" + u.RawQuery
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
replacement += "#" + u.EscapedFragment()
|
||||
}
|
||||
if span.html {
|
||||
replacement = html.EscapeString(replacement)
|
||||
}
|
||||
out = append(out, text[last:span.start]...)
|
||||
out = append(out, replacement...)
|
||||
last = span.end
|
||||
}
|
||||
return append(out, text[last:]...)
|
||||
}
|
||||
return transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) }), nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImageMoveUpdatesLocalReferences(t *testing.T) {
|
||||
v := newVault(t)
|
||||
if err := os.MkdirAll(filepath.Join(v.Root(), "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v.Root(), "assets/原图.png"), []byte("image"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "\n[ref]: <../assets/原图.png>\n<img src=\"../assets/原图.png?q=1&x=2\">\n``\n\n```md\n\n```\n\n\n"
|
||||
if _, err := v.Write("notes/a.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, err := v.Read("notes/a.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.ReplaceAll(content, "../assets/原图.png", "../elsewhere/新图%20%23.png")
|
||||
want = strings.ReplaceAll(want, "``", "``")
|
||||
want = strings.ReplaceAll(want, "```md\n", "```md\n")
|
||||
if string(out) != want {
|
||||
t.Fatalf("链接改写不符:\n%s\n期望:\n%s", out, want)
|
||||
}
|
||||
if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg", nil); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatalf("不能用重命名改变图片格式:%v", err)
|
||||
}
|
||||
item, err := v.Delete("elsewhere/新图 #.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Assets != "" {
|
||||
t.Fatal("图片不应带走同名笔记附件目录")
|
||||
}
|
||||
if _, err := v.Restore(item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _, err := v.OpenRaw("elsewhere/新图 #.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) {
|
||||
v := newVault(t)
|
||||
link, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.ReplaceImage(link, "replacement.PNG", strings.NewReader("replacement"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newLink, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
|
||||
if err != nil || 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"} {
|
||||
data, err := os.ReadFile(filepath.Join(v.Root(), name))
|
||||
if err != nil || string(data) != want {
|
||||
t.Fatalf("图片内容错误:%q %q %v", name, data, err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path, name, data string
|
||||
limit int64
|
||||
want error
|
||||
}{
|
||||
{link, "x.jpg", "new", 100, ErrInvalidPath},
|
||||
{link, "x.png", "", 100, ErrInvalidPath},
|
||||
{link, "x.png", "too big", 1, ErrTooLarge},
|
||||
{"missing.png", "x.png", "new", 100, ErrNotFound},
|
||||
{"../escape.png", "x.png", "new", 100, ErrInvalidPath},
|
||||
{"a.md", "x.md", "new", 100, ErrInvalidPath},
|
||||
} {
|
||||
if err := v.ReplaceImage(tc.path, tc.name, strings.NewReader(tc.data), tc.limit); !errors.Is(err, tc.want) {
|
||||
t.Errorf("替换 %s:得到 %v,期望 %v", tc.path, err, tc.want)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(v.Root(), link))
|
||||
if err != nil || string(data) != "replacement" {
|
||||
t.Fatalf("无效替换不能影响原图:%q %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagesDoNotExposeOrModifySymlinks(t *testing.T) {
|
||||
v := newVault(t)
|
||||
outside := filepath.Join(t.TempDir(), "private.png")
|
||||
if err := os.WriteFile(outside, []byte("private"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(v.Root(), "link.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := v.Tree()
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("图片列表不应公开符号链接:%v %v", entries, err)
|
||||
}
|
||||
if err := v.ReplaceImage("link.png", "x.png", strings.NewReader("new"), 100); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatalf("不能替换仓库外文件:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveCommitFailureRestoresExactReferences(t *testing.T) {
|
||||
for _, source := range []string{"from.png", "from.md"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
v := newVault(t)
|
||||
content := ""
|
||||
if _, err := v.Write("from.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(v.Root(), "from.assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v.Root(), "from.png"), []byte("image"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := "\n"
|
||||
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure := errors.New("关联数据提交失败")
|
||||
target := strings.Replace(source, "from", "to", 1)
|
||||
if _, err := v.Move(source, target, func() error { return failure }); !errors.Is(err, failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{"from.md": content, "refs.md": refs} {
|
||||
got, _, err := v.Read(name)
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("回滚须保留原始正文:%s %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), target)); !os.IsNotExist(err) {
|
||||
t.Fatalf("失败后目标仍存在:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "from.assets")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,9 +146,7 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(path.Ext(rel)) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
|
||||
default:
|
||||
if !IsImage(rel) {
|
||||
continue
|
||||
}
|
||||
replacement, found := replacements[rel]
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestImportZipAssetsAndMove(t *testing.T) {
|
||||
if err := v.Mkdir("移动目标"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md"); err != nil {
|
||||
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, _, err := v.Read("移动目标/改名.md")
|
||||
|
||||
+87
-52
@@ -144,11 +144,6 @@ func (v *Vault) checkInside(p string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Vault) rel(abs string) string {
|
||||
r, _ := filepath.Rel(v.root, abs)
|
||||
return filepath.ToSlash(r)
|
||||
}
|
||||
|
||||
// ---- 读写 ----
|
||||
|
||||
func (v *Vault) Read(rel string) ([]byte, string, error) {
|
||||
@@ -295,42 +290,32 @@ func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
|
||||
|
||||
// ---- 列表 ----
|
||||
|
||||
// skipName 判断目录树中不展示的条目:隐藏文件/目录(含临时文件)与 .assets 图片目录
|
||||
func skipName(name string, dir bool) bool {
|
||||
return strings.HasPrefix(name, ".") || (dir && strings.HasSuffix(name, AssetsExt))
|
||||
// IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。
|
||||
func IsImage(p string) bool {
|
||||
switch strings.ToLower(path.Ext(p)) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Tree 列出所有文件夹与 .md 笔记
|
||||
// Tree 列出文件夹、笔记和图片,沿用 TreeAt 的隐藏路径与符号链接边界。
|
||||
func (v *Vault) Tree() ([]Entry, error) {
|
||||
entries := []Entry{}
|
||||
err := filepath.WalkDir(v.root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
entries, err := v.TreeAt("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := []Entry{}
|
||||
for _, e := range entries {
|
||||
if e.Dir {
|
||||
e.Size = 0
|
||||
}
|
||||
if p == v.root {
|
||||
return nil
|
||||
if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) {
|
||||
visible = append(visible, e)
|
||||
}
|
||||
if skipName(d.Name(), d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() && !IsNote(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e := Entry{Path: v.rel(p), Dir: d.IsDir(), ModTime: info.ModTime().UnixMilli()}
|
||||
if !e.Dir {
|
||||
e.Size = info.Size()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
return nil
|
||||
})
|
||||
return entries, err
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。
|
||||
@@ -413,9 +398,10 @@ func (v *Vault) ScopedPath(root, rel string) (string, error) {
|
||||
|
||||
// ---- 移动 / 重命名 ----
|
||||
|
||||
// Move 移动或重命名笔记/文件夹,返回是否原子改写了正文(改变文件身份)。
|
||||
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
|
||||
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
|
||||
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
// commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。
|
||||
func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) {
|
||||
src, err := v.abs(from)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -436,8 +422,10 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
isNote := !info.IsDir()
|
||||
if isNote && (!IsNote(from) || !IsNote(to)) {
|
||||
isNote := !info.IsDir() && IsNote(from)
|
||||
isImage := !info.IsDir() && IsImage(from)
|
||||
validTarget := (isNote && IsNote(to)) || (isImage && strings.EqualFold(path.Ext(from), path.Ext(to)))
|
||||
if !info.IsDir() && (!info.Mode().IsRegular() || !validTarget) {
|
||||
return false, ErrInvalidPath
|
||||
}
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
@@ -452,6 +440,14 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 图片改名影响引用它的笔记:先读取全部待改正文,失败时不能先移动图片。
|
||||
var links []imageLinkChange
|
||||
if isImage {
|
||||
links, err = v.imageLinkChanges(from, to)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -459,10 +455,22 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
assetsMoved := false
|
||||
writtenLinks := 0
|
||||
var originalNote []byte
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if rewritten {
|
||||
if rollback := atomicWrite(dst, originalNote); 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))
|
||||
}
|
||||
}
|
||||
if assetsMoved {
|
||||
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback))
|
||||
@@ -472,6 +480,12 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
|
||||
}
|
||||
}()
|
||||
for _, change := range links {
|
||||
if err = atomicWrite(change.path, change.after); err != nil {
|
||||
return false, err
|
||||
}
|
||||
writtenLinks++
|
||||
}
|
||||
if srcAssets != "" {
|
||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
||||
@@ -485,11 +499,18 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
|
||||
return true, atomicWrite(dst, out)
|
||||
if err = atomicWrite(dst, out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
originalNote = data
|
||||
rewritten = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
if commit != nil {
|
||||
err = commit()
|
||||
}
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
// ---- 回收站 ----
|
||||
@@ -502,7 +523,7 @@ func newTrashID() string {
|
||||
|
||||
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
|
||||
|
||||
// Delete 把笔记(连同 .assets)或文件夹移入回收站
|
||||
// Delete 把笔记(连同 .assets)、图片或文件夹移入回收站
|
||||
func (v *Vault) Delete(rel string) (item TrashItem, err error) {
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
@@ -517,11 +538,11 @@ func (v *Vault) Delete(rel string) (item TrashItem, err error) {
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
if !info.IsDir() && !IsNote(rel) {
|
||||
if !info.IsDir() && (!info.Mode().IsRegular() || (!IsNote(rel) && !IsImage(rel))) {
|
||||
return TrashItem{}, ErrInvalidPath
|
||||
}
|
||||
item = TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()}
|
||||
if !item.Dir {
|
||||
if !item.Dir && IsNote(rel) {
|
||||
st, statErr := os.Stat(AssetsDir(p))
|
||||
if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) {
|
||||
return TrashItem{}, statErr
|
||||
@@ -700,21 +721,35 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
|
||||
dir := AssetsDir(p)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if err := v.checkInside(filepath.Join(dir, name)); err != nil {
|
||||
if err := v.checkInside(dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(target); errors.Is(err, fs.ErrNotExist) {
|
||||
if err := atomicWrite(target, data); err != nil {
|
||||
// 管理界面允许替换图片,哈希名称不再保证内容未变;不能把旧内容上传误判为重复。
|
||||
base := strings.TrimSuffix(name, path.Ext(name))
|
||||
for i := 0; ; i++ {
|
||||
candidate := name
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d%s", base, i, path.Ext(name))
|
||||
}
|
||||
target := filepath.Join(dir, candidate)
|
||||
if err := v.checkInside(target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
existing, err := os.ReadFile(target)
|
||||
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 EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + candidate, nil
|
||||
}
|
||||
return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + name, nil
|
||||
}
|
||||
|
||||
func assetName(data []byte, origName string) string {
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
}
|
||||
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||
destination := strings.Repeat("a", 250) + ".md"
|
||||
if _, err := v.Move("n.md", destination); err == nil {
|
||||
if _, err := v.Move("n.md", destination, nil); err == nil {
|
||||
t.Fatal("图片目录移动应失败")
|
||||
}
|
||||
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" {
|
||||
@@ -170,7 +170,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if !strings.HasPrefix(link, "笔记%20A.assets/") {
|
||||
t.Fatalf("资源链接不符: %s", link)
|
||||
}
|
||||
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten {
|
||||
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md", nil); err != nil || !rewritten {
|
||||
t.Fatalf("应报告正文改写: %v %v", rewritten, err)
|
||||
}
|
||||
data, _, err := v.Read("other/B.md")
|
||||
@@ -184,7 +184,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
|
||||
t.Fatal("图片目录未随笔记移动:", err)
|
||||
}
|
||||
if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||
if _, err := v.Move("other", "other/sub", nil); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("文件夹移入自身应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -221,10 +221,13 @@ func TestDeleteRestore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeHidesMetaAndAssets(t *testing.T) {
|
||||
func TestTreeShowsImagesButHidesMeta(t *testing.T) {
|
||||
v := newVault(t)
|
||||
v.Write("a.md", []byte("x"), Precondition{})
|
||||
v.SaveAsset("a.md", "i.png", strings.NewReader("i"), 10)
|
||||
link, err := v.SaveAsset("a.md", "i.png", strings.NewReader("i"), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.Mkdir("folder")
|
||||
os.WriteFile(filepath.Join(v.Root(), "note.txt"), []byte("x"), 0o644)
|
||||
entries, err := v.Tree()
|
||||
@@ -235,7 +238,7 @@ func TestTreeHidesMetaAndAssets(t *testing.T) {
|
||||
for _, e := range entries {
|
||||
paths = append(paths, e.Path)
|
||||
}
|
||||
if strings.Join(paths, ",") != "a.md,folder" {
|
||||
if strings.Join(paths, ",") != "a.assets,"+link+",a.md,folder" {
|
||||
t.Fatalf("目录树不符: %v", paths)
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,11 @@ func diff(prev, next map[string]fileState) []events.Event {
|
||||
}
|
||||
case old.dir != cur.dir:
|
||||
structural = true
|
||||
case !cur.dir && cur.rev == "" && (old.size != cur.size || old.mtime != cur.mtime || !os.SameFile(old.info, cur.info)):
|
||||
// 图片没有正文版本号;替换后仍需刷新列表元数据与预览。
|
||||
if vault.IsImage(p) {
|
||||
structural = true
|
||||
}
|
||||
case !cur.dir && old.rev != cur.rev:
|
||||
evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev})
|
||||
}
|
||||
|
||||
@@ -170,3 +170,21 @@ func TestNotesExcludeResourceContents(t *testing.T) {
|
||||
t.Fatalf("跟踪普通文件移动不能改变笔记索引范围: %v", indexed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageReplacementRefreshesTree(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.png")
|
||||
if err := os.WriteFile(p, []byte("image"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := fileState{info: info, size: 5, mtime: 1}
|
||||
updated := fileState{info: info, size: 5, mtime: 2}
|
||||
evs := diff(map[string]fileState{"a.png": old}, map[string]fileState{"a.png": updated})
|
||||
if len(evs) != 1 || evs[0].Type != "tree" {
|
||||
t.Fatalf("同路径同大小替换须刷新预览:%v", evs)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user