feat(ui): streamline workspace settings and remove sharing
Unify compact workspace controls and panel transitions, move directory actions into the context menu, and remove backlink UI. Reorganize settings with searchable sections, per-setting resets, shortcut search, and accurate save feedback. Remove sharing UI, APIs, authentication helpers, and storage code; provide a transactional cleanup script with retirement tests. Reject directories during orphan image cleanup and cover mixed-batch preservation.
This commit is contained in:
1 parent
a716794e41
commit
19aab631ec
63 files changed
+1077
-3343
No files matched your search
@@ -1,9 +1,6 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type CleanupResult struct {
|
||||
Deleted []TrashItem `json:"deleted"`
|
||||
@@ -15,8 +12,7 @@ type CleanupSkip struct {
|
||||
}
|
||||
|
||||
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
|
||||
// protected 返回当前有效分享的主体路径,由业务数据拥有者在检查时读取。
|
||||
func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)) (CleanupResult, error) {
|
||||
func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
|
||||
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
@@ -41,10 +37,6 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)
|
||||
references[ref.Target] = true
|
||||
}
|
||||
}
|
||||
scopes, err := protected()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
if seen[p] {
|
||||
@@ -58,11 +50,11 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)
|
||||
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
|
||||
}
|
||||
info, err := v.Stat(p)
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
} else if !info.Mode().IsRegular() {
|
||||
reason = "不是图片文件"
|
||||
}
|
||||
}
|
||||
if reason != "" {
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
|
||||
func TestCleanupChecksFreshReferences(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"} {
|
||||
for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png"} {
|
||||
full := filepath.Join(v.Root(), p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -22,8 +22,8 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
|
||||
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 {
|
||||
result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png", "../outside.png"})
|
||||
if err != nil || len(result.Deleted) != 3 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 2 {
|
||||
t.Fatalf("清理结果不符:%+v %v", result, err)
|
||||
}
|
||||
if _, err := v.Restore(result.Deleted[0].ID); err != nil {
|
||||
@@ -32,24 +32,13 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
|
||||
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 })
|
||||
result, err = v.CleanupImages([]string{"assets/orphan.png"})
|
||||
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) {
|
||||
@@ -86,7 +75,7 @@ func TestCleanupProtectsLinkedImages(t *testing.T) {
|
||||
if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link {
|
||||
t.Fatalf("链接内图片未记录引用:%+v", metadata)
|
||||
}
|
||||
result, err := v.CleanupImages([]string{link}, func() ([]string, error) { return nil, nil })
|
||||
result, err := v.CleanupImages([]string{link})
|
||||
if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" {
|
||||
t.Fatalf("链接内图片未受保护:%+v %v", result, err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
|
||||
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 {
|
||||
if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, err := v.Read("notes/a.md")
|
||||
@@ -33,7 +33,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
|
||||
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) {
|
||||
if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatalf("不能用重命名改变图片格式:%v", err)
|
||||
}
|
||||
item, err := v.Delete("elsewhere/新图 #.png")
|
||||
@@ -62,7 +62,7 @@ func TestImageMovePreservesMultilineMarkdownAndIgnoresLiteralPaths(t *testing.T)
|
||||
if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("old.png", "new.png", nil); err != nil {
|
||||
if _, err := v.Move("old.png", "new.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := v.Read("文章.md")
|
||||
@@ -138,42 +138,3 @@ func TestImagesDoNotExposeOrModifySymlinks(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestImportZipAssetsAndMove(t *testing.T) {
|
||||
if err := v.Mkdir("移动目标"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil {
|
||||
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, _, err := v.Read("移动目标/改名.md")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -42,7 +41,7 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
|
||||
if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("dir/a.md", "new/renamed.md", nil); err != nil {
|
||||
if _, err := v.Move("dir/a.md", "new/renamed.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _ := v.Read("new/renamed.md")
|
||||
@@ -53,25 +52,13 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
|
||||
if string(body) != "[入链](../new/renamed.md?q=1#section)" {
|
||||
t.Fatalf("入链错误:%s", body)
|
||||
}
|
||||
if _, err := v.Move("new", "deep/folder", nil); err != nil {
|
||||
if _, err := v.Move("new", "deep/folder"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _ = v.Read("deep/folder/renamed.md")
|
||||
if !strings.Contains(string(body), "../../other.md") {
|
||||
t.Fatalf("目录出链错误:%s", body)
|
||||
}
|
||||
original, _, _ := v.Read("other.md")
|
||||
failure := errors.New("提交失败")
|
||||
if _, err := v.Move("deep/folder", "rollback", func() error { return failure }); !errors.Is(err, failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _ = v.Read("other.md")
|
||||
if string(body) != string(original) {
|
||||
t.Fatalf("入链未回滚:%s", body)
|
||||
}
|
||||
if _, _, err := v.Read("deep/folder/renamed.md"); err != nil {
|
||||
t.Fatal("移动主体未回滚", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) {
|
||||
@@ -102,7 +89,7 @@ func TestMovePreservesEntityShapedFilenames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
const target = "a©.md"
|
||||
if _, err := v.Move("target.md", target, nil); err != nil {
|
||||
if _, err := v.Move("target.md", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := v.Read("source.md")
|
||||
|
||||
+1
-47
@@ -362,46 +362,11 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
|
||||
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 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
|
||||
// commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。
|
||||
func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) {
|
||||
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
src, err := v.abs(from)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -451,7 +416,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return false, err
|
||||
}
|
||||
assetsMoved := false
|
||||
writtenLinks := 0
|
||||
defer func() {
|
||||
if err == nil {
|
||||
@@ -462,11 +426,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
||||
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))
|
||||
}
|
||||
}
|
||||
if rollback := os.Rename(dst, src); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
|
||||
}
|
||||
@@ -484,11 +443,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
||||
}
|
||||
assetsMoved = true
|
||||
|
||||
}
|
||||
if commit != nil {
|
||||
err = commit()
|
||||
}
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
@@ -62,7 +62,12 @@ func TestAssetSymlinkEscapeRejected(t *testing.T) {
|
||||
|
||||
func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
v := newVault(t)
|
||||
if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil {
|
||||
content := "原文 "
|
||||
if _, err := v.Write("n.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := "[原文](n.md) [目标](" + strings.Repeat("a", 250) + ".md)"
|
||||
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
|
||||
@@ -70,11 +75,13 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
}
|
||||
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||
destination := strings.Repeat("a", 250) + ".md"
|
||||
if _, err := v.Move("n.md", destination, nil); err == nil {
|
||||
if _, err := v.Move("n.md", destination); err == nil {
|
||||
t.Fatal("图片目录移动应失败")
|
||||
}
|
||||
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" {
|
||||
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
||||
for name, want := range map[string]string{"n.md": content, "refs.md": refs} {
|
||||
if got, _, err := v.Read(name); err != nil || string(got) != want {
|
||||
t.Fatalf("原笔记与引用未恢复: %s %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -170,7 +177,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", nil); err != nil || !rewritten {
|
||||
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")
|
||||
@@ -184,7 +191,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", nil); !errors.Is(err, ErrInvalidPath) {
|
||||
if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("文件夹移入自身应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user