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:
eezd committed 2026-10-24 12:32:23 +08:00
1 parent a716794e41
commit 19aab631ec
63 files changed
+1077 -3343

No files matched your search

+7 -15
View File
@@ -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 != "" {
+6 -17
View File
@@ -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)
}
+3 -42
View File
@@ -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 := "![](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)
}
})
}
}
+1 -1
View File
@@ -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")
+3 -16
View File
@@ -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
View File
@@ -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
}
+13 -6
View File
@@ -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 := "原文 ![](n.assets/x.png)"
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("文件夹移入自身应被拒绝")
}
}