diff --git a/README.md b/README.md index 05e3fc5..633fe21 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ docker run -d --name rawmark \ - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 +- 图片管理:展开侧栏的 `assets` 或 `笔记名.assets`,点击图片预览,可重命名、替换或移入回收站。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 分享:命令面板(F1)中「分享当前笔记」生成只读链接 `/s/…`,可设访问密码与有效期;「管理分享链接」查看与撤销。 diff --git a/internal/api/api.go b/internal/api/api.go index 7735a41..5ce5099 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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")) diff --git a/internal/api/images_test.go b/internal/api/images_test.go new file mode 100644 index 0000000..871567e --- /dev/null +++ b/internal/api/images_test.go @@ -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", "![](assets/a.png)") + 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) != "![](assets/a.png)" { + 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) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index fd97845..863b4a5 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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": { diff --git a/internal/vault/images.go b/internal/vault/images.go new file mode 100644 index 0000000..f3f1c09 --- /dev/null +++ b/internal/vault/images.go @@ -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 +} diff --git a/internal/vault/images_test.go b/internal/vault/images_test.go new file mode 100644 index 0000000..d51ac8f --- /dev/null +++ b/internal/vault/images_test.go @@ -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 := "![图](../assets/原图.png?q=1#part \"标题\")\n[ref]: <../assets/原图.png>\n\n`![](../assets/原图.png)`\n\n```md\n![](../assets/原图.png)\n```\n![](https://example.com/assets/原图.png)\n![](../assets/其他.png)\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, "`![](../elsewhere/新图%20%23.png)`", "`![](../assets/原图.png)`") + want = strings.ReplaceAll(want, "```md\n![](../elsewhere/新图%20%23.png)", "```md\n![](../assets/原图.png)") + 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 := "![](from.assets/a.png)" + 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 := "![](from.png)\n![](to.png)" + 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) + } + }) + } +} diff --git a/internal/vault/import_links.go b/internal/vault/import_links.go index a6f5c3a..98e1366 100644 --- a/internal/vault/import_links.go +++ b/internal/vault/import_links.go @@ -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] diff --git a/internal/vault/import_test.go b/internal/vault/import_test.go index e62fc97..e57c940 100644 --- a/internal/vault/import_test.go +++ b/internal/vault/import_test.go @@ -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") diff --git a/internal/vault/vault.go b/internal/vault/vault.go index b7cea67..f767e2f 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -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 { diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 6046011..2d0d2e0 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -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) } } diff --git a/internal/watch/watch.go b/internal/watch/watch.go index 9b81159..411927a 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -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}) } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 749ac80..00fa99f 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -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) + } +} diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index e2f684c..043f9ce 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -6,6 +6,7 @@ const paths = { import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5', folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6', folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', + image: 'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 16l6-6 12 11M14 8h.01', note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6', chevron: 'm9 6 6 6-6 6', more: 'M12 6h.01M12 12h.01M12 18h.01', diff --git a/web/src/app/ImageDialog.vue b/web/src/app/ImageDialog.vue new file mode 100644 index 0000000..dfc7b5d --- /dev/null +++ b/web/src/app/ImageDialog.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/web/src/app/MoveDialog.vue b/web/src/app/MoveDialog.vue index bfb793c..4921584 100644 --- a/web/src/app/MoveDialog.vue +++ b/web/src/app/MoveDialog.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { fuzzyMatch, segments } from './fuzzy' import Icon from './Icon.vue' -import { basename, dirname, joinPath, noteTitle } from './paths' +import { basename, dirname, inAssetDir, joinPath, noteTitle } from './paths' import { move, ws } from './workspace' // 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快) @@ -20,7 +20,7 @@ const title = computed(() => (isDir.value ? basename(props.path) : noteTitle(pro const targets = computed(() => { const here = dirname(props.path) const inside = (d: string) => isDir.value && (d === props.path || d.startsWith(props.path + '/')) - const dirs = ws.entries.filter((e) => e.dir && e.path !== here && !inside(e.path)).map((e) => e.path) + const dirs = ws.entries.filter((e) => e.dir && !inAssetDir(e.path) && e.path !== here && !inside(e.path)).map((e) => e.path) return here === '' ? dirs : ['', ...dirs] }) diff --git a/web/src/app/QuickSwitcher.vue b/web/src/app/QuickSwitcher.vue index 63cf23c..0a2fc40 100644 --- a/web/src/app/QuickSwitcher.vue +++ b/web/src/app/QuickSwitcher.vue @@ -41,7 +41,7 @@ function cancel() { function noteItems(q: string): Item[] { const recentRank = new Map(ws.recent.map((p, i) => [p, i])) - const notes = ws.entries.filter((e) => !e.dir) + const notes = ws.entries.filter((e) => !e.dir && e.path.endsWith(NOTE_EXT)) const toItem = (path: string, positions: number[] = []): Item => { const dir = dirname(path) const offset = dir ? dir.length + 1 : 0 diff --git a/web/src/app/TrashPanel.vue b/web/src/app/TrashPanel.vue index 25a544b..f906850 100644 --- a/web/src/app/TrashPanel.vue +++ b/web/src/app/TrashPanel.vue @@ -2,6 +2,7 @@ import { onMounted, ref } from 'vue' import { api, type TrashItem } from './api' import Icon from './Icon.vue' +import { isImage } from './paths' import { restore, toast } from './workspace' const items = ref(null) @@ -41,7 +42,7 @@ onMounted(load)

回收站是空的