From 84e89c8fe408f48e5ac3c317044fdb3575560a97 Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 20 Oct 2026 17:20:23 +0800 Subject: [PATCH] Support live recursive folder shares and follow subject moves --- internal/api/api.go | 9 ++- internal/api/api_test.go | 129 ++++++++++++++++++++++++++++++++-- internal/api/openapi.json | 55 +++++++++++---- internal/api/shares.go | 88 +++++++++++++++++------ internal/events/hub.go | 2 +- internal/vault/vault.go | 127 ++++++++++++++++++++++++++------- internal/vault/vault_test.go | 8 +-- internal/watch/watch.go | 85 ++++++++++++++++------ internal/watch/watch_test.go | 101 +++++++++++++++++++++----- web/src/app/ShareDialog.vue | 6 +- web/src/app/SharePanel.vue | 10 +-- web/src/app/TreeNode.vue | 2 +- web/src/app/api.ts | 2 +- web/src/app/workspace.test.ts | 22 ++++++ web/src/app/workspace.ts | 2 +- web/src/share/main.ts | 95 +++++++++++++++++++++++-- web/src/share/share.css | 4 ++ 17 files changed, 620 insertions(+), 127 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 794c789..fc878cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -355,17 +355,22 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) { if !readJSON(w, r, &body) { return } - if err := s.vault.Move(body.From, body.To); err != nil { + rewritten, err := s.vault.Move(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 { + 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"}) + } writeJSON(w, 200, map[string]bool{"ok": true}) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 6eedd5f..a664f08 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -37,6 +37,33 @@ type fixture struct { admin *http.Client } +func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) { + f := newFixture(t) + f.write("from.md", "![](from.assets/x.png)") + if err := os.Mkdir(filepath.Join(f.root, "from.assets"), 0o755); err != nil { + t.Fatal(err) + } + ch, cancel := f.hub.Subscribe() + defer cancel() + res := f.do(f.admin, "POST", "/api/v1/move", `{"from":"from.md","to":"to.md"}`, nil) + defer res.Body.Close() + if res.StatusCode != 200 { + t.Fatalf("移动失败: %d", res.StatusCode) + } + select { + case msg := <-ch: + var batch []events.Event + if err := json.Unmarshal(msg, &batch); err != nil { + t.Fatal(err) + } + if len(batch) != 2 || batch[0].Type != "moved" || batch[0].From != "from.md" || batch[0].Path != "to.md" || batch[1].Type != "tree" { + t.Fatalf("移动事件不符: %s", msg) + } + case <-time.After(time.Second): + t.Fatal("正文改写后未广播移动事件") + } +} + func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) { dir := t.TempDir() v, err := vault.Open(filepath.Join(dir, "vault")) @@ -352,7 +379,7 @@ func TestShareAccess(t *testing.T) { } } -func TestShareFilenameToken(t *testing.T) { +func TestShareRandomToken(t *testing.T) { f := newFixture(t) name := "计划 #1? 50% +;\"" p := "目录/" + name + ".md" @@ -363,10 +390,9 @@ func TestShareFilenameToken(t *testing.T) { t.Fatal(err) } sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil)) - suffix, ok := strings.CutPrefix(sh.Token, name+"-") - random, err := base64.RawURLEncoding.DecodeString(suffix) - if !ok || err != nil || len(random) != 16 { - t.Fatal("分享应包含完整文件名和 128 位随机字符,不包含目录") + random, err := base64.RawURLEncoding.DecodeString(sh.Token) + if err != nil || len(random) != 16 { + t.Fatal("分享标识应为 128 位随机字符,不包含文件名称或目录") } other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil)) if other.Token == sh.Token { @@ -398,6 +424,97 @@ func TestShareFilenameToken(t *testing.T) { } } +func TestFolderShareTracksItsCurrentContentsAndSubject(t *testing.T) { + f := newFixture(t) + f.write("公开/子目录/文章.md", "第一版") + f.write("公开/子目录/文章.assets/图.png", "PNG") + f.write("公开/资料.txt", "资料") + f.write("公开/.私密.md", "不能公开") + f.write("公开/.git/config", "不能公开") + f.write("其他/私密.md", "不能公开") + if err := os.Symlink(filepath.Join(f.root, "其他"), filepath.Join(f.root, "公开", "别名")); err != nil { + t.Fatal(err) + } + folder := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开","password":"访问密码"}`, nil)) + note := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开/子目录/文章.md"}`, nil)) + base := "/api/v1/public/shares/" + folder.Token + noteBase := "/api/v1/public/shares/" + note.Token + guest := newClient() + if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 401 { + t.Fatalf("目录内的文章也必须解锁,得到 %d", code) + } + if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 { + t.Fatal(code) + } + type directory struct { + Title string + Dir bool + Entries []vault.Entry + } + listed := decode[directory](t, f.do(guest, "GET", base, "", nil)) + if listed.Title != "公开" || !listed.Dir || len(listed.Entries) != 5 { + t.Fatalf("应递归列出全部普通文件和子目录: %+v", listed) + } + for _, e := range listed.Entries { + if strings.Contains(e.Path, "公开/") || strings.Contains(e.Path, "私密") || strings.Contains(e.Path, "别名") || strings.Contains(e.Path, ".git") { + t.Fatalf("目录列表越界: %s", e.Path) + } + } + f.write("公开/子目录/文章.md", "最新正文") + article := decode[map[string]any](t, f.do(guest, "GET", base+"?path=子目录/文章.md", "", nil)) + if article["content"] != "最新正文" || article["path"] != nil { + t.Fatalf("应提供当前正文且不暴露仓库路径: %v", article) + } + for _, rel := range []string{"../其他/私密.md", ".私密.md", ".git/config", "别名/私密.md"} { + if code := f.status(guest, "GET", base+"?path="+url.QueryEscape(rel), "", nil); code != 404 { + t.Fatalf("不允许读取 %s,得到 %d", rel, code) + } + if code := f.status(guest, "GET", base+"/assets/"+url.PathEscape(rel), "", nil); code != 404 { + t.Fatalf("资源接口不允许读取 %s,得到 %d", rel, code) + } + } + if code := f.status(guest, "GET", base+"/assets/子目录/文章.assets/图.png", "", nil); code != 200 { + t.Fatalf("目录中的图片不可读: %d", code) + } + res := f.do(guest, "GET", base+"/assets/资料.txt?download=1", "", nil) + content, err := io.ReadAll(res.Body) + if err != nil || res.StatusCode != 200 || string(content) != "资料" || !strings.HasPrefix(res.Header.Get("Content-Disposition"), "attachment;") { + t.Fatalf("普通文件下载失败: %d %v", res.StatusCode, err) + } + f.write("公开/新增.md", "新内容") + listed = decode[directory](t, f.do(guest, "GET", base, "", nil)) + if len(listed.Entries) != 6 { + t.Fatal("新增文件应自动进入目录分享") + } + if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"公开","to":"移动后的目录"}`, nil); code != 200 { + t.Fatal(code) + } + listed = decode[directory](t, f.do(guest, "GET", base, "", nil)) + if listed.Title != "移动后的目录" { + t.Fatal("目录主体移动后原分享应跟随") + } + if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"移动后的目录/子目录/文章.md","to":"其他/移出.md"}`, nil); code != 200 { + t.Fatal(code) + } + if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 404 { + t.Fatalf("移出的文章不能继续通过目录分享读取: %d", code) + } + article = decode[map[string]any](t, f.do(guest, "GET", noteBase, "", nil)) + if article["content"] != "最新正文" || article["title"] != "移出" { + t.Fatal("单篇分享应跟随文章移出目录") + } + file := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"移动后的目录/资料.txt"}`, nil)) + if code := f.status(guest, "GET", "/api/v1/public/shares/"+file.Token+"/assets/", "", nil); code != 200 { + t.Fatalf("应能分享普通文件: %d", code) + } + if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+folder.Token, "", nil); code != 200 { + t.Fatal(code) + } + if code := f.status(guest, "GET", base+"/assets/资料.txt", "", nil); code != 404 { + t.Fatalf("撤销后资源也必须失效: %d", code) + } +} + func TestShareWithoutPasswordAndExpiry(t *testing.T) { f := newFixture(t) f.write("a.md", "公开") @@ -414,7 +531,7 @@ func TestShareWithoutPasswordAndExpiry(t *testing.T) { if code := f.status(newClient(), "GET", "/api/v1/public/shares/expired", "", nil); code != 404 { t.Errorf("过期分享应为 404,得到 %d", code) } - for _, body := range []string{`{"path":"a.assets/x.png"}`, `{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} { + for _, body := range []string{`{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} { if code := f.status(f.admin, "POST", "/api/v1/shares", body, nil); code != 400 { t.Errorf("%s 应为 400,得到 %d", body, code) } diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 58a1607..aeaa556 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1046,7 +1046,7 @@ } }, "post": { - "summary": "创建只读分享", + "summary": "创建文件或文件夹的只读分享", "tags": [ "分享" ], @@ -1063,7 +1063,8 @@ "type": "object", "properties": { "path": { - "type": "string" + "type": "string", + "description": "仓库内的文件或文件夹;文件夹递归公开当前所有普通文件和子目录,含后续新增或移入的内容" }, "password": { "type": "string", @@ -1083,7 +1084,7 @@ }, "responses": { "200": { - "description": "新分享,token 为文件名(不含 .md)加连字符和随机字符,访问地址为 /s/{URL 编码后的 token};文件移动或改名不改变已生成的链接", + "description": "新分享,token 为 128 位随机数的 URL 安全编码,访问地址为 /s/{token};移动分享主体不改变链接", "content": { "application/json": { "schema": { @@ -1093,7 +1094,7 @@ } }, "400": { - "description": "不是笔记", + "description": "路径或参数无效", "content": { "application/json": { "schema": { @@ -1314,10 +1315,18 @@ "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "文件夹分享中相对主体的子路径;省略时读取主体本身,不允许跳出分享范围", + "schema": { + "type": "string" + } } ], "get": { - "summary": "访客读取分享的笔记", + "summary": "访客读取分享主体或其目录中的当前内容", "tags": [ "分享(公开)" ], @@ -1326,7 +1335,7 @@ ], "responses": { "200": { - "description": "笔记", + "description": "目录返回 dir=true 及递归 entries(路径相对此目录);Markdown 返回 dir=false、content 与 updatedAt;其他文件返回 dir=false,内容经 assets 接口读取或下载", "content": { "application/json": { "schema": { @@ -1340,6 +1349,15 @@ }, "updatedAt": { "type": "integer" + }, + "dir": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entry" + } } } } @@ -1365,7 +1383,7 @@ } }, "404": { - "description": "分享不存在、已过期或笔记已删除", + "description": "分享不存在、已过期、主体已删除或子路径不在分享范围内", "content": { "application/json": { "schema": { @@ -1472,14 +1490,25 @@ "name": "path", "in": "path", "required": true, - "description": "相对笔记所在目录的图片路径,只能位于该笔记的 .assets 目录", + "description": "文件夹分享中相对主体的文件路径;单文件分享仅允许其 .assets 图片,空路径读取该文件本身", "schema": { "type": "string" } + }, + { + "name": "download", + "in": "query", + "description": "设为 1 时以附件下载;其他情况下以带沙箱 CSP 的原始文件返回", + "schema": { + "type": "string", + "enum": [ + "1" + ] + } } ], "get": { - "summary": "访客读取分享笔记中的图片", + "summary": "访客读取或下载分享范围内的普通文件", "tags": [ "分享(公开)" ], @@ -2163,10 +2192,12 @@ "type": "object", "properties": { "token": { - "type": "string" + "type": "string", + "description": "新分享使用完全随机的 URL 安全标识,不包含文件名称或目录" }, "path": { - "type": "string" + "type": "string", + "description": "分享主体在仓库中的路径(只在登录后的管理接口返回)" }, "createdAt": { "type": "integer" @@ -2180,7 +2211,7 @@ }, "exists": { "type": "boolean", - "description": "笔记是否仍存在" + "description": "分享的文件或文件夹是否仍存在" } } }, diff --git a/internal/api/shares.go b/internal/api/shares.go index 2a058c1..fa1e769 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "log/slog" + "mime" "net/http" "path" "strings" @@ -25,7 +26,7 @@ const shareCookieTTL = 30 * 24 * time.Hour type shareView struct { store.Share HasPassword bool `json:"hasPassword"` - // 笔记是否仍存在(被删除或在外部改名后为 false) + // 分享主体是否仍存在。 Exists bool `json:"exists"` } @@ -62,8 +63,8 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { return } p, err := vault.CleanPath(body.Path) - if err != nil || !vault.IsNote(p) { - writeError(w, 400, "只能分享笔记") + if err != nil { + writeError(w, 400, "分享路径无效") return } if _, err := s.vault.Stat(p); err != nil { @@ -76,7 +77,7 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { } now := time.Now() sh := store.Share{ - Token: strings.TrimSuffix(path.Base(p), vault.NoteExt) + "-" + auth.NewShareToken(), + Token: auth.NewShareToken(), Path: p, PasswordHash: auth.HashSharePassword(body.Password), CreatedAt: now.UnixMilli(), @@ -147,30 +148,55 @@ func (s *Server) loadShare(token string) (store.Share, bool, error) { return sh, true, nil } -// publicShare 返回笔记标题与当前内容(不含所在目录,避免泄露仓库结构) +// publicShare 只公开主体本身或分享目录内的内容,不返回主体在仓库中的路径。 func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) { sh, ok := s.openShare(w, r) if !ok { return } - data, _, err := s.vault.Read(sh.Path) - if errors.Is(err, vault.ErrNotFound) { - writeError(w, 404, "分享的笔记已不存在") - return - } - if err != nil { - internalError(w, r, err) - return - } info, err := s.vault.Stat(sh.Path) if err != nil { vaultError(w, r, err) return } + p := sh.Path + if rel := r.URL.Query().Get("path"); rel != "" { + if !info.IsDir() { + writeError(w, 404, "文件不存在") + return + } + p, err = s.vault.ScopedPath(sh.Path, rel) + if err == nil { + info, err = s.vault.Stat(p) + } + if err != nil { + vaultError(w, r, err) + return + } + } + if info.IsDir() { + entries, err := s.vault.TreeAt(p) + if err != nil { + vaultError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": true, "entries": entries}) + return + } + if !vault.IsNote(p) { + writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": false}) + return + } + data, _, err := s.vault.Read(p) + if err != nil { + vaultError(w, r, err) + return + } writeJSON(w, 200, map[string]any{ - "title": strings.TrimSuffix(path.Base(sh.Path), vault.NoteExt), + "title": strings.TrimSuffix(path.Base(p), vault.NoteExt), "content": string(data), "updatedAt": info.ModTime().UnixMilli(), + "dir": false, }) } @@ -223,18 +249,40 @@ func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) } -// publicShareAsset 输出分享笔记中的图片:路径相对笔记所在目录,且只能是该笔记 .assets 目录内的文件 +// publicShareAsset 提供主体允许范围内的普通文件;单文件分享额外允许其 .assets。 func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) { sh, ok := s.openShare(w, r) if !ok { return } - rel := path.Join(path.Dir(sh.Path), r.PathValue("path")) - if !strings.HasPrefix(rel, vault.AssetsDir(sh.Path)+"/") { - writeError(w, 404, "文件不存在") + info, err := s.vault.Stat(sh.Path) + if err != nil { + vaultError(w, r, err) return } - s.serveRaw(w, r, rel) + rel := r.PathValue("path") + var target string + if info.IsDir() { + target, err = s.vault.ScopedPath(sh.Path, rel) + } else if rel == "" { + target = sh.Path + } else { + assets := vault.AssetsDir(sh.Path) + prefix := path.Base(assets) + "/" + if !vault.IsNote(sh.Path) || !strings.HasPrefix(rel, prefix) { + writeError(w, 404, "文件不存在") + return + } + target, err = s.vault.ScopedPath(assets, strings.TrimPrefix(rel, prefix)) + } + if err != nil { + vaultError(w, r, err) + return + } + if r.URL.Query().Get("download") == "1" { + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(target)})) + } + s.serveRaw(w, r, target) } // FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理, diff --git a/internal/events/hub.go b/internal/events/hub.go index ee147ac..abaa204 100644 --- a/internal/events/hub.go +++ b/internal/events/hub.go @@ -12,7 +12,7 @@ import ( // tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树 // changed 笔记内容变化,Rev 为新版本 // deleted 笔记被删除 -// moved 笔记被移动(From -> Path),内容版本不变 +// moved 文件或目录被移动(From -> Path),目录事件包含其子项的路径映射 // settings 应用设置变化,客户端重新读取 type Event struct { Type string `json:"type"` diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 45e3a1b..f037501 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -49,10 +49,11 @@ type Vault struct { } type Entry struct { - Path string `json:"path"` - Dir bool `json:"dir"` - Size int64 `json:"size"` - ModTime int64 `json:"mtime"` // 毫秒 + Path string `json:"path"` + Dir bool `json:"dir"` + Size int64 `json:"size"` + ModTime int64 `json:"mtime"` // 毫秒 + Info fs.FileInfo `json:"-"` } type TrashItem struct { @@ -168,17 +169,14 @@ func (v *Vault) Read(rel string) ([]byte, string, error) { return data, Rev(data), nil } -// Stat 返回笔记的文件信息;不存在时返回 ErrNotFound +// Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound func (v *Vault) Stat(rel string) (fs.FileInfo, error) { - if !IsNote(rel) { - return nil, ErrInvalidPath - } p, err := v.abs(rel) if err != nil { return nil, err } info, err := os.Stat(p) - if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.Mode().IsRegular()) { + if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.Mode().IsRegular() && !info.IsDir()) { return nil, ErrNotFound } return info, err @@ -335,51 +333,130 @@ func (v *Vault) Tree() ([]Entry, error) { return entries, err } +// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。 +func (v *Vault) TreeAt(rel string) ([]Entry, error) { + root := v.root + if rel != "" { + var err error + root, err = v.abs(rel) + if err != nil { + return nil, err + } + } + entries := []Entry{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p == root { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if !info.IsDir() && !info.Mode().IsRegular() { + return nil + } + name, err := filepath.Rel(root, p) + if err != nil { + return err + } + entries = append(entries, Entry{Path: filepath.ToSlash(name), Dir: info.IsDir(), Size: info.Size(), ModTime: info.ModTime().UnixMilli(), Info: info}) + return nil + }) + return entries, err +} + +// ScopedPath 校验公开访问的子路径,并阻止符号链接跳出已分享的目录。 +func (v *Vault) ScopedPath(root, rel string) (string, error) { + if _, err := CleanPath(rel); err != nil { + return "", ErrNotFound + } + base, err := v.abs(root) + if err != nil { + return "", err + } + base, err = filepath.EvalSymlinks(base) + if errors.Is(err, fs.ErrNotExist) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + p := path.Join(root, rel) + full, err := v.abs(p) + if err != nil { + return "", err + } + full, err = filepath.EvalSymlinks(full) + if errors.Is(err, fs.ErrNotExist) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if full != filepath.Join(base, filepath.FromSlash(rel)) { + return "", ErrNotFound + } + return p, nil +} + // ---- 移动 / 重命名 ---- -// Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接 -func (v *Vault) Move(from, to string) (err error) { +// Move 移动或重命名笔记/文件夹,返回是否原子改写了正文(改变文件身份)。 +// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。 +func (v *Vault) Move(from, to string) (rewritten bool, err error) { src, err := v.abs(from) if err != nil { - return err + return false, err } dst, err := v.abs(to) if err != nil { - return err + return false, err } if from == to || strings.HasPrefix(to, from+"/") { - return ErrInvalidPath + return false, ErrInvalidPath } v.mu.Lock() defer v.mu.Unlock() info, err := os.Lstat(src) if errors.Is(err, fs.ErrNotExist) { - return ErrNotFound + return false, ErrNotFound } if err != nil { - return err + return false, err } isNote := !info.IsDir() if isNote && (!IsNote(from) || !IsNote(to)) { - return ErrInvalidPath + return false, ErrInvalidPath } if _, err := os.Lstat(dst); err == nil { - return ErrExists + return false, ErrExists } srcAssets, dstAssets := "", "" if isNote { if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() { srcAssets, dstAssets = AssetsDir(src), AssetsDir(dst) if _, err := os.Lstat(dstAssets); err == nil { - return ErrExists + return false, ErrExists } } } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err + return false, err } if err := os.Rename(src, dst); err != nil { - return err + return false, err } assetsMoved := false defer func() { @@ -397,7 +474,7 @@ func (v *Vault) Move(from, to string) (err error) { }() if srcAssets != "" { if err := os.Rename(srcAssets, dstAssets); err != nil { - return fmt.Errorf("移动图片目录失败: %w", err) + return false, fmt.Errorf("移动图片目录失败: %w", err) } assetsMoved = true oldBase := strings.TrimSuffix(path.Base(from), NoteExt) @@ -405,14 +482,14 @@ func (v *Vault) Move(from, to string) (err error) { if oldBase != newBase { data, err := os.ReadFile(dst) if err != nil { - return err + return false, err } if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) { - return atomicWrite(dst, out) + return true, atomicWrite(dst, out) } } } - return nil + return false, nil } // ---- 回收站 ---- diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 6be1ed0..6046011 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); err == nil { t.Fatal("图片目录移动应失败") } if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" { @@ -170,8 +170,8 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) { if !strings.HasPrefix(link, "笔记%20A.assets/") { t.Fatalf("资源链接不符: %s", link) } - if err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil { - t.Fatal(err) + if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten { + t.Fatalf("应报告正文改写: %v %v", rewritten, err) } data, _, err := v.Read("other/B.md") if err != nil { @@ -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"); !errors.Is(err, ErrInvalidPath) { t.Fatal("文件夹移入自身应被拒绝") } } diff --git a/internal/watch/watch.go b/internal/watch/watch.go index 334cb7a..ab23031 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -7,6 +7,8 @@ import ( "log/slog" "os" "path/filepath" + "sort" + "strings" "time" "github.com/fsnotify/fsnotify" @@ -26,6 +28,7 @@ type fileState struct { size int64 mtime int64 rev string + info os.FileInfo } type Watcher struct { @@ -102,8 +105,17 @@ func (w *Watcher) rescan() { func notes(snap map[string]fileState) map[string]string { out := make(map[string]string, len(snap)) for p, st := range snap { - if !st.dir { - out[p] = st.rev + if !st.dir && vault.IsNote(p) { + visible := true + for _, segment := range strings.Split(p, "/") { + if strings.HasSuffix(segment, vault.AssetsExt) { + visible = false + break + } + } + if visible { + out[p] = st.rev + } } } return out @@ -111,25 +123,27 @@ func notes(snap map[string]fileState) map[string]string { // scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算 func (w *Watcher) scan() (map[string]fileState, error) { - entries, err := w.v.Tree() + entries, err := w.v.TreeAt("") if err != nil { return nil, err } next := make(map[string]fileState, len(entries)) dirs := map[string]bool{w.v.Root(): true} for _, e := range entries { - st := fileState{dir: e.Dir, size: e.Size, mtime: e.ModTime} + st := fileState{dir: e.Dir, size: e.Size, mtime: e.Info.ModTime().UnixNano(), info: e.Info} abs := filepath.Join(w.v.Root(), filepath.FromSlash(e.Path)) if e.Dir { dirs[abs] = true - } else if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime { - st.rev = old.rev - } else { - data, err := os.ReadFile(abs) - if err != nil { - continue // 扫描期间被删除,下一轮会处理 + } else if vault.IsNote(e.Path) { + if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime && os.SameFile(old.info, st.info) { + st.rev = old.rev + } else { + data, err := os.ReadFile(abs) + if err != nil { + continue // 扫描期间被删除,下一轮会处理 + } + st.rev = vault.Rev(data) } - st.rev = vault.Rev(data) } next[e.Path] = st } @@ -150,28 +164,36 @@ func (w *Watcher) scan() (map[string]fileState, error) { return next, nil } -// diff 比较两次快照:内容相同的"删除 + 新增"视为移动 +// diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。 func diff(prev, next map[string]fileState) []events.Event { var evs []events.Event structural := false - removed := map[string][]string{} // rev -> 被删除的路径(内容相同的文件可能有多个) + removed := map[string]fileState{} for p, old := range prev { if _, ok := next[p]; ok { continue } structural = true - if !old.dir { - removed[old.rev] = append(removed[old.rev], p) - } + removed[p] = old } + candidates := map[string]string{} // 新路径 -> 唯一的原路径 + destinations := map[string]int{} for p, cur := range next { old, existed := prev[p] switch { case !existed: structural = true - if froms := removed[cur.rev]; len(froms) > 0 && !cur.dir { - removed[cur.rev] = froms[1:] - evs = append(evs, events.Event{Type: "moved", From: froms[0], Path: p, Rev: cur.rev}) + from := "" + matches := 0 + for oldPath, old := range removed { + if os.SameFile(old.info, cur.info) { + from = oldPath + matches++ + } + } + if matches == 1 { + candidates[p] = from + destinations[from]++ } case old.dir != cur.dir: structural = true @@ -179,11 +201,30 @@ func diff(prev, next map[string]fileState) []events.Event { evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev}) } } - for _, paths := range removed { - for _, p := range paths { - evs = append(evs, events.Event{Type: "deleted", Path: p}) + var moves []events.Event + for to, from := range candidates { + if destinations[from] == 1 { + delete(removed, from) + moves = append(moves, events.Event{Type: "moved", From: from, Path: to, Rev: next[to].rev}) } } + sort.Slice(moves, func(i, j int) bool { return len(moves[i].From) < len(moves[j].From) }) + var emitted []events.Event + for _, move := range moves { + for _, parent := range emitted { + if strings.HasPrefix(move.From, parent.From+"/") { + move.From = parent.Path + strings.TrimPrefix(move.From, parent.From) + } + } + // 先应用父目录映射;子项另有改名时,从映射后的路径继续移动。 + if move.From != move.Path { + emitted = append(emitted, move) + } + } + evs = append(emitted, evs...) + for p := range removed { + evs = append(evs, events.Event{Type: "deleted", Path: p}) + } if structural { evs = append(evs, events.Event{Type: "tree"}) } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 97e5dbc..d53bfdd 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -1,44 +1,61 @@ package watch import ( + "os" + "path/filepath" "sort" "strings" "testing" ) func TestDiff(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "d"), 0o755); err != nil { + t.Fatal(err) + } + for _, p := range []string{"d/a.md", "b.md", "c.md", "c2.md"} { + if err := os.WriteFile(filepath.Join(root, p), []byte("相同内容"), 0o644); err != nil { + t.Fatal(err) + } + } + state := func(p, rev string) fileState { + info, err := os.Stat(filepath.Join(root, p)) + if err != nil { + t.Fatal(err) + } + return fileState{dir: info.IsDir(), rev: rev, info: info} + } prev := map[string]fileState{ - "d": {dir: true}, - "d/a.md": {rev: "r1"}, - "b.md": {rev: "r2"}, - "c.md": {rev: "same"}, - "c2.md": {rev: "same"}, + "d": state("d", ""), + "d/a.md": state("d/a.md", "r1"), + "b.md": state("b.md", "r2"), + "c.md": state("c.md", "same"), + "c2.md": state("c2.md", "same"), + } + for from, to := range map[string]string{"d/a.md": "d/x.md", "c2.md": "e.md"} { + if err := os.Rename(filepath.Join(root, from), filepath.Join(root, to)); err != nil { + t.Fatal(err) + } } next := map[string]fileState{ - "d": {dir: true}, - "d/x.md": {rev: "r1"}, // 由 d/a.md 移动而来 - "b.md": {rev: "r3"}, // 内容修改 - "e.md": {rev: "same"}, // c.md 与 c2.md 内容相同:其一视为移动到此,另一个为删除 + "d": state("d", ""), + "d/x.md": state("d/x.md", "r1"), + "b.md": state("b.md", "r3"), + "e.md": state("e.md", "same"), } var got []string for _, e := range diff(prev, next) { f := []string{e.Type} if e.From != "" { - f = append(f, "c*") - if !strings.HasPrefix(e.From, "c") { - f[1] = e.From - } + f = append(f, e.From) } if e.Path != "" { f = append(f, e.Path) - if e.Type == "deleted" && strings.HasPrefix(e.Path, "c") { - f[1] = "c*" - } } got = append(got, strings.Join(f, " ")) } sort.Strings(got) - want := "changed b.md|deleted c*|moved c* e.md|moved d/a.md d/x.md|tree" + want := "changed b.md|deleted c.md|moved c2.md e.md|moved d/a.md d/x.md|tree" if strings.Join(got, "|") != want { t.Fatalf("事件不符:\n got %q\nwant %q", strings.Join(got, "|"), want) } @@ -46,3 +63,53 @@ func TestDiff(t *testing.T) { t.Fatalf("快照相同不应产生事件: %v", evs) } } + +func TestDirectoryMovesCoverChildrenAndEmptyDirectories(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "from", "empty"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "from", "a.md"), []byte("正文"), 0o644); err != nil { + t.Fatal(err) + } + snapshot := func(dir string) map[string]fileState { + out := map[string]fileState{} + for _, rel := range []string{dir, dir + "/empty", dir + "/a.md"} { + info, err := os.Stat(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + out[rel] = fileState{dir: info.IsDir(), info: info, rev: "same"} + } + return out + } + prev := snapshot("from") + if err := os.Rename(filepath.Join(root, "from"), filepath.Join(root, "to")); err != nil { + t.Fatal(err) + } + events := diff(prev, snapshot("to")) + if len(events) != 2 || events[0].Type != "moved" || events[0].From != "from" || events[0].Path != "to" || events[1].Type != "tree" { + t.Fatalf("目录移动应只产生一次路径映射: %v", events) + } + next := snapshot("to") + if err := os.Rename(filepath.Join(root, "to", "a.md"), filepath.Join(root, "to", "b.md")); err != nil { + t.Fatal(err) + } + next["to/b.md"] = next["to/a.md"] + delete(next, "to/a.md") + events = diff(prev, next) + if len(events) != 3 || events[0].From != "from" || events[0].Path != "to" || events[1].From != "to/a.md" || events[1].Path != "to/b.md" || events[2].Type != "tree" { + t.Fatalf("同一批次内子项改名应接续父目录映射: %v", events) + } +} + +func TestNotesExcludeResourceContents(t *testing.T) { + indexed := notes(map[string]fileState{ + "笔记.md": {rev: "r1"}, + "笔记.assets/说明.md": {rev: "r2"}, + "附件.txt": {}, + }) + if len(indexed) != 1 || indexed["笔记.md"] != "r1" { + t.Fatalf("跟踪普通文件移动不能改变笔记索引范围: %v", indexed) + } +} diff --git a/web/src/app/ShareDialog.vue b/web/src/app/ShareDialog.vue index 725c157..77f58f7 100644 --- a/web/src/app/ShareDialog.vue +++ b/web/src/app/ShareDialog.vue @@ -1,6 +1,6 @@