diff --git a/README.md b/README.md index 784ff38..f726f95 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ docker run -d --name rawmark \ | `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 | | `RAWMARK_ADDR` | `:8080` | 监听地址 | +文件树默认使用 📂 / 📄,可点击图标或右键“修改图标”,选择表情或内置线性图标。自定义属性存于仓库的 `.rawmark/tree-icons.json`,随应用内移动、重命名及回收站恢复保留,不改动或导出到 Markdown 正文;外部文件操作不保证跟随。 + ## 开发 需要 Go 1.27+ 与 Node 24+。开发时不必构建前端或 Docker 镜像,一键启动: diff --git a/internal/api/api.go b/internal/api/api.go index c4df570..0e3053c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -76,6 +76,7 @@ func (s *Server) routes() []route { {"POST /api/v1/auth/password", sessionOnly, s.changePassword}, {"GET /api/v1/tree", authed, s.tree}, + {"PUT /api/v1/tree-icon", authed, s.setTreeIcon}, {"GET /api/v1/metadata", authed, s.metadata}, {"POST /api/v1/export", authed, s.export}, {"GET /api/v1/files/{path...}", authed, s.readFile}, @@ -290,6 +291,27 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"entries": entries}) } +func (s *Server) setTreeIcon(w http.ResponseWriter, r *http.Request) { + var body struct { + Path string `json:"path"` + Icon json.RawMessage `json:"icon"` + } + if !readJSON(w, r, &body) { + return + } + var icon *vault.TreeIcon + if len(body.Icon) == 0 || json.Unmarshal(body.Icon, &icon) != nil { + writeError(w, 400, "请求格式错误:需要 icon(null 恢复默认)") + return + } + if err := s.vault.SetTreeIcon(body.Path, icon); err != nil { + vaultError(w, r, err) + return + } + s.hub.Broadcast(events.Event{Type: "tree"}) + writeJSON(w, 200, map[string]bool{"ok": true}) +} + const searchLimit = 50 func (s *Server) search(w http.ResponseWriter, r *http.Request) { @@ -355,6 +377,9 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) { if rewritten { // 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。 s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"}) + } else { + // 图标提交在文件移动之后;不能仅依赖文件监听器较早的通知。 + s.hub.Broadcast(events.Event{Type: "tree"}) } writeJSON(w, 200, map[string]bool{"ok": true}) } @@ -461,6 +486,7 @@ func (s *Server) trashRestore(w http.ResponseWriter, r *http.Request) { vaultError(w, r, err) return } + s.hub.Broadcast(events.Event{Type: "tree"}) writeJSON(w, 200, item) } @@ -511,7 +537,7 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.As(err, &conflict): writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev}) - case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport): + case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon): writeError(w, 400, err.Error()) case errors.Is(err, vault.ErrNotFound): writeError(w, 404, err.Error()) diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 9b34c47..5baaff3 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1967,6 +1967,61 @@ } } } + }, + "/tree-icon": { + "put": { + "summary": "设置单个文件或目录的图标,null 恢复默认", + "tags": [ + "文件" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path", + "icon" + ], + "properties": { + "path": { + "type": "string" + }, + "icon": { + "oneOf": [ + { + "$ref": "#/components/schemas/TreeIcon" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "400": { + "description": "非法路径或图标" + }, + "404": { + "description": "文件不存在" + } + } + } } }, "components": { @@ -2018,6 +2073,9 @@ "mtime": { "type": "integer", "description": "修改时间(Unix 毫秒)" + }, + "icon": { + "$ref": "#/components/schemas/TreeIcon" } } }, @@ -2155,6 +2213,26 @@ "type": "boolean" } } + }, + "TreeIcon": { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "emoji", + "builtin" + ] + }, + "value": { + "type": "string", + "description": "Emoji 字符或内置线性图标名称,最多64字节" + } + } } } } diff --git a/internal/api/tree_icons_test.go b/internal/api/tree_icons_test.go new file mode 100644 index 0000000..df34572 --- /dev/null +++ b/internal/api/tree_icons_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "net/http" + "rawmark/internal/events" + "rawmark/internal/vault" + "testing" + "time" +) + +func TestTreeIconAuthAndBroadcast(t *testing.T) { + f := newFixture(t) + f.write("a.md", "正文") + payload := `{"path":"a.md","icon":{"kind":"emoji","value":"📚"}}` + check := func(client *http.Client, headers map[string]string, want int) { + t.Helper() + res := f.do(client, "PUT", "/api/v1/tree-icon", payload, headers) + res.Body.Close() + if res.StatusCode != want { + t.Fatalf("设置图标状态: %d,期望 %d", res.StatusCode, want) + } + } + check(newClient(), nil, 401) + for _, scope := range []string{"read", "write"} { + token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"icons","scope":"`+scope+`"}`, nil)) + want := 200 + if scope == "read" { + want = 403 + } + check(newClient(), map[string]string{"Authorization": "Bearer " + token.Token}, want) + } + ch, cancel := f.hub.Subscribe() + defer cancel() + check(f.admin, nil, 200) + select { + case msg := <-ch: + var batch []events.Event + if err := json.Unmarshal(msg, &batch); err != nil || len(batch) != 1 || batch[0].Type != "tree" { + t.Fatalf("图标广播不符: %s %v", msg, err) + } + case <-time.After(time.Second): + t.Fatal("缺少 tree 广播") + } + tree := decode[struct{ Entries []vault.Entry }](t, f.do(f.admin, "GET", "/api/v1/tree", "", nil)) + if len(tree.Entries) != 1 || tree.Entries[0].Icon == nil || tree.Entries[0].Icon.Value != "📚" { + t.Fatalf("目录属性不符: %+v", tree) + } + res := f.do(f.admin, "PUT", "/api/v1/tree-icon", `{"path":"a.md","icon":null}`, nil) + res.Body.Close() + if res.StatusCode != 200 { + t.Fatal(res.StatusCode) + } + tree = decode[struct{ Entries []vault.Entry }](t, f.do(f.admin, "GET", "/api/v1/tree", "", nil)) + if tree.Entries[0].Icon != nil { + t.Fatal("恢复默认后不应返回覆盖属性") + } + for _, body := range []string{`{"path":"a.md"}`, `{"path":"a.md","icon":"📚"}`, `{"path":"a.md","icon":{"kind":"builtin","value":"fake"}}`, `{"path":"../a.md","icon":null}`} { + res := f.do(f.admin, "PUT", "/api/v1/tree-icon", body, nil) + res.Body.Close() + if res.StatusCode != 400 { + t.Fatal(res.StatusCode) + } + } +} diff --git a/internal/vault/tree_icons.go b/internal/vault/tree_icons.go new file mode 100644 index 0000000..c72a929 --- /dev/null +++ b/internal/vault/tree_icons.go @@ -0,0 +1,124 @@ +package vault + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "unicode/utf8" +) + +// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。 +type TreeIcon struct { + Kind string `json:"kind"` + Value string `json:"value"` +} + +var ErrInvalidIcon = errors.New("非法图标") + +const builtinIcons = " plus eye expand graph calendar tag copy task quote undo redo print grid menu noteAdd import folderAdd folder image note chevron more close pin trash logout restore edit search link key settings palette user outline source command info " + +var emojiPattern = regexp.MustCompile(`^[\p{So}\p{Sk}\p{Sm}\x{2139}\x{203C}\x{2049}\x{3030}\x{303D}\x{200D}\x{FE0F}\x{20E3}0-9#*\x{E0020}-\x{E007F}]+$`) +var emojiBase = regexp.MustCompile(`[\p{So}\p{Sm}\x{2139}\x{203C}\x{2049}\x{3030}\x{303D}\x{20E3}]`) + +func (icon TreeIcon) valid() bool { + switch icon.Kind { + case "builtin": + return icon.Value != "" && !strings.ContainsAny(icon.Value, " \t\r\n") && strings.Contains(builtinIcons, " "+icon.Value+" ") + case "emoji": + return utf8.ValidString(icon.Value) && len(icon.Value) <= 64 && emojiPattern.MatchString(icon.Value) && emojiBase.MatchString(icon.Value) + default: + return false + } +} + +func (v *Vault) readTreeIcons() (map[string]TreeIcon, error) { + data, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json")) + if errors.Is(err, fs.ErrNotExist) { + return map[string]TreeIcon{}, nil + } + if err != nil { + return nil, err + } + var icons map[string]TreeIcon + if err := json.Unmarshal(data, &icons); err != nil { + return nil, fmt.Errorf("文件树图标元数据损坏: %w", err) + } + if icons == nil { + return nil, errors.New("文件树图标元数据必须是对象") + } + for p, icon := range icons { + if _, err := CleanPath(p); err != nil || !icon.valid() { + return nil, errors.New("文件树图标元数据含非法路径或图标") + } + } + return icons, nil +} + +func (v *Vault) writeTreeIcons(icons map[string]TreeIcon) error { + data, err := json.Marshal(icons) + if err != nil { + return err + } + return atomicWrite(filepath.Join(v.root, metaDir, "tree-icons.json"), data) +} + +func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error { + if icon != nil && !icon.valid() { + return ErrInvalidIcon + } + p, err := v.abs(rel) + if err != nil { + return err + } + 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.IsDir() && (!info.Mode().IsRegular() || (!IsNote(rel) && !IsImage(rel))) { + return ErrInvalidPath + } + icons, err := v.readTreeIcons() + if err != nil { + return err + } + if icon == nil { + if _, ok := icons[rel]; !ok { + return nil + } + delete(icons, rel) + } else { + if icons[rel] == *icon { + return nil + } + icons[rel] = *icon + } + return v.writeTreeIcons(icons) +} + +// 从所属子树移出属性,供移动和回收站操作在原有写锁内提交。 +func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon { + taken := map[string]TreeIcon{} + for p, icon := range icons { + if p == root || directory && strings.HasPrefix(p, root+"/") { + taken[p] = icon + delete(icons, p) + } + } + return taken +} + +// 回收站保留属性,但普通回收站 API 不暴露内部元数据。 +type trashMetadata struct { + TrashItem + Icons map[string]TreeIcon `json:"icons,omitempty"` +} diff --git a/internal/vault/tree_icons_test.go b/internal/vault/tree_icons_test.go new file mode 100644 index 0000000..2d3dd68 --- /dev/null +++ b/internal/vault/tree_icons_test.go @@ -0,0 +1,215 @@ +package vault + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func setIcon(t *testing.T, v *Vault, path string, icon *TreeIcon) { + t.Helper() + if err := v.SetTreeIcon(path, icon); err != nil { + t.Fatal(err) + } +} + +func treeIcons(t *testing.T, v *Vault) map[string]TreeIcon { + t.Helper() + entries, err := v.Tree() + if err != nil { + t.Fatal(err) + } + result := map[string]TreeIcon{} + for _, entry := range entries { + if entry.Icon != nil { + result[entry.Path] = *entry.Icon + } + } + return result +} + +func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) { + v := newVault(t) + if _, err := os.Stat(filepath.Join(v.root, metaDir, "tree-icons.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("未修改时不应生成图标文件", err) + } + if _, err := v.Write("folder/nested/note.md", []byte("# 正文\n"), Precondition{}); err != nil { + t.Fatal(err) + } + folder, note := TreeIcon{"emoji", "📚"}, TreeIcon{"builtin", "task"} + setIcon(t, v, "folder", &folder) + setIcon(t, v, "folder/nested/note.md", ¬e) + body, rev, err := v.Read("folder/nested/note.md") + if err != nil || string(body) != "# 正文\n" || rev != Rev([]byte("# 正文\n")) { + t.Fatal("图标修改了正文/版本", err) + } + v, err = Open(v.root) + if err != nil { + t.Fatal(err) + } + if _, err := v.Move("folder/nested/note.md", "folder/nested/renamed.md"); err != nil { + t.Fatal(err) + } + if _, err := v.Move("folder", "destination/moved"); err != nil { + t.Fatal(err) + } + want := map[string]TreeIcon{"destination/moved": folder, "destination/moved/nested/renamed.md": note} + if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { + t.Fatalf("改名/移动后图标不符: %v", got) + } + item, err := v.Delete("destination/moved") + if err != nil { + t.Fatal(err) + } + if got := treeIcons(t, v); len(got) != 0 { + t.Fatalf("删除后保留了属性: %v", got) + } + if _, err := v.Write("destination/moved/nested/renamed.md", []byte("新内容"), Precondition{}); err != nil { + t.Fatal(err) + } + if got := treeIcons(t, v); len(got) != 0 { + t.Fatalf("同名新文件继承了属性: %v", got) + } + if _, err := v.Restore(item.ID); !errors.Is(err, ErrExists) { + t.Fatal("恢复冲突应失败", err) + } + if _, err := v.Move("destination/moved", "replacement"); err != nil { + t.Fatal(err) + } + if _, err := v.Restore(item.ID); err != nil { + t.Fatal(err) + } + if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { + t.Fatalf("恢复后图标不符: %v", got) + } + setIcon(t, v, "destination/moved", nil) + delete(want, "destination/moved") + if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { + t.Fatalf("恢复默认失败: %v", got) + } + // 原有无图标的回收站条目也可恢复。 + item, err = v.Delete("replacement") + if err != nil { + t.Fatal(err) + } + if _, err := v.Restore(item.ID); err != nil { + t.Fatal(err) + } +} + +func TestTreeIconValidationAndCorruptMetadata(t *testing.T) { + v := newVault(t) + if _, err := v.Write("a.md", []byte("body"), Precondition{}); err != nil { + t.Fatal(err) + } + for _, value := range []string{"📂", "👨‍👩‍👧‍👦", "👩🏽‍💻", "🇨🇳", "1️⃣", "©️", "ℹ️", "↔️", "◼️", "‼️", "〰️", "🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f"} { + setIcon(t, v, "a.md", &TreeIcon{"emoji", value}) + } + for _, icon := range []TreeIcon{{"url", "https://example.com"}, {"builtin", "__proto__"}, {"builtin", "folder note"}, {"emoji", ""}, {"emoji", "hello"}, {"emoji", ""}, {"emoji", "\u200d"}, {"emoji", strings.Repeat("📂", 17)}} { + if err := v.SetTreeIcon("a.md", &icon); !errors.Is(err, ErrInvalidIcon) { + t.Fatalf("应拒绝 %+v: %v", icon, err) + } + } + if err := v.SetTreeIcon("../a.md", nil); !errors.Is(err, ErrInvalidPath) { + t.Fatal(err) + } + if err := v.SetTreeIcon("missing.md", nil); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(v.root, "a.md"), filepath.Join(v.root, "link.md")); err != nil { + t.Fatal(err) + } + if err := v.SetTreeIcon("link.md", nil); !errors.Is(err, ErrInvalidPath) { + t.Fatal("不应设置符号链接图标", err) + } + for _, data := range []string{"{", "null", `{"../a.md":{"kind":"emoji","value":"📂"}}`, `{"a.md":{"kind":"builtin","value":"absent"}}`} { + if err := os.WriteFile(filepath.Join(v.root, metaDir, "tree-icons.json"), []byte(data), 0644); err != nil { + t.Fatal(err) + } + if _, err := v.Tree(); err == nil { + t.Fatal("损坏元数据不应静默清空") + } + if _, err := v.Move("a.md", "b.md"); err == nil { + t.Fatal("元数据异常时应停止移动") + } + if _, err := v.Delete("a.md"); err == nil { + t.Fatal("元数据异常时应停止删除") + } + if _, _, err := v.Read("a.md"); err != nil { + t.Fatal("原件应保持可用", err) + } + } +} + +func TestTreeIconWriteFailureRollsBack(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限") + } + for _, operation := range []string{"set", "move", "delete", "restore"} { + t.Run(operation, func(t *testing.T) { + v := newVault(t) + if _, err := v.Write("a.md", []byte("[link](other.md)"), Precondition{}); err != nil { + t.Fatal(err) + } + legacyAsset(t, v, "a.md", "p.png", "image") + icon := TreeIcon{"emoji", "📚"} + setIcon(t, v, "a.md", &icon) + var item TrashItem + var err error + if operation == "restore" { + item, err = v.Delete("a.md") + if err != nil { + t.Fatal(err) + } + } + before, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json")) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(v.root, metaDir), 0555); err != nil { + t.Fatal(err) + } + defer os.Chmod(filepath.Join(v.root, metaDir), 0755) + switch operation { + case "set": + err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"}) + case "move": + _, err = v.Move("a.md", "new/b.md") + case "delete": + _, err = v.Delete("a.md") + case "restore": + _, err = v.Restore(item.ID) + } + if err == nil { + t.Fatal("图标元数据写失败却返回成功") + } + after, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json")) + if err != nil || string(before) != string(after) { + t.Fatal("原有元数据被改变", err) + } + if operation == "restore" { + if _, err := v.Stat("a.md"); !errors.Is(err, ErrNotFound) { + t.Fatal("恢复应撤回", err) + } + metadata, err := v.readTrashMetadata(item.ID) + if err != nil || metadata.Icons["a.md"] != icon { + t.Fatal("回收站图标丢失", err) + } + } else { + body, _, err := v.Read("a.md") + if err != nil || string(body) != "[link](other.md)" { + t.Fatal("原件或正文未回滚", err) + } + if _, err := os.Stat(filepath.Join(v.root, "a.assets")); err != nil { + t.Fatal("附件未回滚", err) + } + if _, err := v.Stat("new/b.md"); !errors.Is(err, ErrNotFound) { + t.Fatal("移动目标未撤回", err) + } + } + }) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index ca61e03..acb3544 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -54,6 +54,7 @@ type Entry struct { Size int64 `json:"size"` ModTime int64 `json:"mtime"` // 毫秒 Info fs.FileInfo `json:"-"` + Icon *TreeIcon `json:"icon,omitempty"` } type TrashItem struct { @@ -306,12 +307,19 @@ func (v *Vault) Tree() ([]Entry, error) { if err != nil { return nil, err } + icons, err := v.readTreeIcons() + if err != nil { + return nil, err + } visible := []Entry{} for _, e := range entries { if e.Dir { e.Size = 0 } if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) { + if icon, ok := icons[e.Path]; ok { + e.Icon = &icon + } visible = append(visible, e) } } @@ -396,6 +404,15 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) { if _, err := os.Lstat(dst); err == nil { return false, ErrExists } + icons, err := v.readTreeIcons() + if err != nil { + return false, err + } + movedIcons := takeTreeIcons(icons, from, info.IsDir()) + iconsChanged := len(movedIcons) > 0 + for p, icon := range movedIcons { + icons[movedPath(p, from, to, info.IsDir())] = icon + } srcAssets, dstAssets := "", "" if isNote { if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() { @@ -405,6 +422,13 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) { } } } + if srcAssets != "" { + attached := takeTreeIcons(icons, AssetsDir(from), true) + iconsChanged = iconsChanged || len(attached) > 0 + for p, icon := range attached { + icons[movedPath(p, AssetsDir(from), AssetsDir(to), true)] = icon + } + } var images []imageTransfer var imageTargets map[string]string defer func() { @@ -486,6 +510,9 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) { images[i].removed = true } } + if iconsChanged { + err = v.writeTreeIcons(icons) + } return rewritten, err } @@ -531,16 +558,32 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) { item.Assets = filepath.Base(AssetsDir(p)) } } + icons, err := v.readTreeIcons() + if err != nil { + return TrashItem{}, err + } + metadata := trashMetadata{TrashItem: item, Icons: takeTreeIcons(icons, rel, item.Dir)} + if item.Assets != "" { + for p, icon := range takeTreeIcons(icons, AssetsDir(rel), true) { + metadata.Icons[p] = icon + } + } dir := filepath.Join(v.trash, item.ID) if err := os.MkdirAll(dir, 0o755); err != nil { return TrashItem{}, err } - noteMoved := false + noteMoved, assetsMoved := false, false defer func() { if err == nil { return } rollbackFailed := false + if assetsMoved { + if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚回收站图片失败: %w", rollback)) + rollbackFailed = true + } + } if noteMoved { if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback)) @@ -554,7 +597,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) { } } }() - meta, _ := json.Marshal(item) + meta, _ := json.Marshal(metadata) if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil { return item, err } @@ -567,25 +610,39 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) { return item, fmt.Errorf("移动图片目录到回收站失败: %w", err) } } - return item, nil + assetsMoved = item.Assets != "" + if len(metadata.Icons) > 0 { + err = v.writeTreeIcons(icons) + } + return item, err } func (v *Vault) readTrashItem(id string) (TrashItem, error) { + metadata, err := v.readTrashMetadata(id) + return metadata.TrashItem, err +} + +func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) { if !trashIDPattern.MatchString(id) { - return TrashItem{}, ErrInvalidPath + return trashMetadata{}, ErrInvalidPath } data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json")) if errors.Is(err, fs.ErrNotExist) { - return TrashItem{}, ErrNotFound + return trashMetadata{}, ErrNotFound } if err != nil { - return TrashItem{}, err + return trashMetadata{}, err } - var item TrashItem - if err := json.Unmarshal(data, &item); err != nil { - return TrashItem{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err) + var metadata trashMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err) } - return item, nil + for p, icon := range metadata.Icons { + if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) { + return trashMetadata{}, fmt.Errorf("回收站条目 %s 图标元数据损坏", id) + } + } + return metadata, nil } func (v *Vault) TrashList() ([]TrashItem, error) { @@ -609,7 +666,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) { } func (v *Vault) Restore(id string) (item TrashItem, err error) { - item, err = v.readTrashItem(id) + metadata, err := v.readTrashMetadata(id) + item = metadata.TrashItem if err != nil { return TrashItem{}, err } @@ -619,6 +677,13 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { } v.mu.Lock() defer v.mu.Unlock() + icons, err := v.readTreeIcons() + if err != nil { + return TrashItem{}, err + } + for p, icon := range metadata.Icons { + icons[p] = icon + } if _, err := os.Lstat(dst); err == nil { return TrashItem{}, ErrExists } @@ -645,7 +710,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { err = errors.Join(err, rollback) return } - meta, _ := json.Marshal(item) + meta, _ := json.Marshal(metadata) if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil { err = errors.Join(err, rollback) } @@ -666,7 +731,13 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { assetsMoved = true } cleaning = true - return item, os.RemoveAll(dir) + if err = os.RemoveAll(dir); err != nil { + return item, err + } + if len(metadata.Icons) > 0 { + err = v.writeTreeIcons(icons) + } + return item, err } func (v *Vault) Purge(id string) error { diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index ae5e166..810ae7f 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -1,47 +1,6 @@ @@ -58,6 +17,6 @@ defineProps<{ name: IconName; size?: number }>() stroke-linejoin="round" aria-hidden="true" > - + diff --git a/web/src/app/Sidebar.vue b/web/src/app/Sidebar.vue index 8261a01..cc41b4e 100644 --- a/web/src/app/Sidebar.vue +++ b/web/src/app/Sidebar.vue @@ -3,8 +3,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import Icon from './Icon.vue' import { basename } from './paths' import { commandTitle } from './commands' -import { dropDirectory, tree } from './tree' +import { dropDirectory, tree, treeIconTarget } from './tree' import TreeNode from './TreeNode.vue' +import TreeIconPicker from './TreeIconPicker.vue' import SearchPanel from './SearchPanel.vue' import { createFolder, createNote, move, openSearch, ws } from './workspace' import { sidebarViews } from '../plugins/registry' @@ -89,6 +90,7 @@ onUnmounted(() => { window.removeEventListener('blur', clearDrop) window.removeEventListener('keydown', onDragKeydown) clearDrop() + treeIconTarget.value = null }) @@ -96,8 +98,8 @@ onUnmounted(() => {
+
-
+
diff --git a/web/src/app/TreeIconPicker.vue b/web/src/app/TreeIconPicker.vue new file mode 100644 index 0000000..636295c --- /dev/null +++ b/web/src/app/TreeIconPicker.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 065be75..ab14880 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -2,7 +2,7 @@ import { computed, nextTick, ref, watch } from 'vue' import Icon from './Icon.vue' import { basename, dirname, inAssetDir, isImage, joinPath } from './paths' -import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, type TreeNode } from './tree' +import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget, type TreeNode } from './tree' import { batchFiles, exportFiles } from './batch' import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace' @@ -78,7 +78,7 @@ function commitRename() { if (target && target !== props.node.path) void move(props.node.path, target) } -function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'delete' | 'export') { +function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'icon' | 'rename' | 'move' | 'delete' | 'export') { menuOpen.value = false const path = props.node.path if (action === 'note' || action === 'folder') { @@ -86,12 +86,18 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm void (action === 'note' ? createNote(path) : createFolder(path)) } else if (action === 'import') ws.importing = path else if (action === 'images') { ws.imagesAll = false; ws.images = path } + else if (action === 'icon') editIcon() else if (action === 'rename') ws.renaming = path else if (action === 'move') ws.moving = [...selectedPaths] else if (action === 'export') void exportFiles([...selectedPaths]) else void batchFiles('delete', [...selectedPaths]) } +function editIcon(event?: MouseEvent) { + const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget : row.value?.querySelector('.rm-tree-icon') + if (anchor) treeIconTarget.value = { path: props.node.path, anchor } +} + function showMenu() { contextSelection(props.node.path) menuOpen.value = true @@ -152,6 +158,7 @@ function onDrop(e: DragEvent) { :draggable="!isRenaming && !assetDir" role="treeitem" :aria-selected="selectedPaths.has(node.path)" + :aria-expanded="node.dir ? isOpen : undefined" @click="activate" @contextmenu.prevent.stop="showMenu" @touchstart.passive="onTouchStart" @@ -163,6 +170,12 @@ function onDrop(e: DragEvent) { @drop="onDrop" > + - {{ node.dir ? node.name : node.name.replace(/\.md$/, '') }} +
- + +
+
    + +
+
+
diff --git a/web/src/app/api.ts b/web/src/app/api.ts index d67ca96..dec140b 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -1,10 +1,15 @@ +import type { IconName } from './icons' + // REST API 客户端,与服务端 internal/api 对应 +export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName } + export interface Entry { path: string dir: boolean size: number mtime: number + icon?: TreeIcon } export interface ImportResult { @@ -144,6 +149,7 @@ export const api = { exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }), metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')), + setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }), tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries, search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results, diff --git a/web/src/app/app.css b/web/src/app/app.css index ef26a3c..1b20d4e 100644 --- a/web/src/app/app.css +++ b/web/src/app/app.css @@ -156,7 +156,7 @@ button { .rm-tree-row { -webkit-touch-callout: none; } .rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; } .rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; } -.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; } +.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform var(--rm-panel-duration) var(--rm-panel-easing); } .rm-tree-chevron.open { transform: rotate(90deg); } .rm-tree-chevron.hidden { visibility: hidden; } .rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -631,7 +631,7 @@ button { /* 轻玻璃工作台 */ .rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; } -.rm-tree-label { flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: left; cursor: pointer; } +.rm-tree-label { margin-left: auto; flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: right; cursor: pointer; } .rm-tree-label:hover { background: var(--rm-hover); } .rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); } .rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); } @@ -713,3 +713,14 @@ button { @media (max-width: 767px), (pointer: coarse) { input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); } } + +/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */ +.rm-tree-children { display: grid; grid-template-rows: 1fr; } +.rm-tree-children > .rm-tree-list { min-height: 0; overflow: hidden; } +.rm-tree-expand-enter-active, .rm-tree-expand-leave-active { transition: grid-template-rows var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); } +.rm-tree-expand-enter-from, .rm-tree-expand-leave-to { grid-template-rows: 0fr; opacity: 0; } +.rm-tree-expand-leave-active { pointer-events: none; } +.rm-emoji { font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; font-weight: normal; font-style: normal; } +.rm-tree-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; } +.rm-tree-icon:hover { background: var(--rm-hover); } +@media (pointer: coarse) { .rm-tree-icon { width: 28px; height: 44px; } } diff --git a/web/src/app/icons.ts b/web/src/app/icons.ts new file mode 100644 index 0000000..ebac84f --- /dev/null +++ b/web/src/app/icons.ts @@ -0,0 +1,43 @@ +// 线性图标(24×24 视框,描边绘制) +export const iconPaths = { + plus: 'M12 5v14M5 12h14', + eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', + expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5', + graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8', + calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16', + tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01', + copy: 'M9 9h12v12H9zM5 15H3V3h12v2', + task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7', + quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6', + undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2', + redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2', + print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z', + grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z', + + menu: 'M4 6h16M4 12h16M4 18h16', + noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6', + 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', + close: 'M18 6 6 18M6 6l12 12', + pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z', + trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6', + logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9', + restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', + edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z', + search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35', + link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71', + key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3', + settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z', + palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01', + user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', + outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01', + source: 'm8 6-6 6 6 6m8-12 6 6-6 6', + command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z', + info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 11v6M12 7h.01', +} +export type IconName = keyof typeof iconPaths diff --git a/web/src/app/tree.ts b/web/src/app/tree.ts index 9f7ef05..2ec9d06 100644 --- a/web/src/app/tree.ts +++ b/web/src/app/tree.ts @@ -1,5 +1,5 @@ -import { computed, reactive, ref, watch } from 'vue' -import type { Entry } from './api' +import { computed, reactive, ref, shallowRef, watch } from 'vue' +import type { Entry, TreeIcon } from './api' import { basename, dirname, hiddenAssetPath } from './paths' import { rangePaths } from './selection' import { ws } from './workspace' @@ -8,6 +8,7 @@ export interface TreeNode { name: string path: string dir: boolean + icon?: TreeIcon children: TreeNode[] } @@ -18,7 +19,7 @@ function build(entries: Entry[]): TreeNode[] { const dirs = new Map() // 先按路径排序,保证父目录先于子项出现 for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) { - const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, children: [] } + const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] } if (e.dir) dirs.set(e.path, node) ;(dirs.get(dirname(e.path))?.children ?? root).push(node) } @@ -32,6 +33,8 @@ function build(entries: Entry[]): TreeNode[] { export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path)))) +export const treeIconTarget = shallowRef<{ path: string; anchor: HTMLElement } | null>(null) + // null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。 export const dropDirectory = ref(null) diff --git a/web/src/app/treeIconCatalog.ts b/web/src/app/treeIconCatalog.ts new file mode 100644 index 0000000..35d6b5f --- /dev/null +++ b/web/src/app/treeIconCatalog.ts @@ -0,0 +1,48 @@ +import type { Gemoji } from 'gemoji' +import type { IconName } from './icons' + +export const iconLabels: Record = { + plus: '加号', eye: '查看', expand: '展开', graph: '关系', calendar: '日历', tag: '标签', copy: '复制', + task: '任务', quote: '引用', undo: '撤销', redo: '重做', print: '打印', grid: '网格', menu: '菜单', + noteAdd: '新建笔记', import: '导入', folderAdd: '新建文件夹', folder: '文件夹', image: '图片', note: '文档', + chevron: '箭头', more: '更多', close: '关闭', pin: '固定', trash: '回收站', logout: '退出', restore: '恢复', + edit: '编辑', search: '搜索', link: '链接', key: '钥匙', settings: '设置', palette: '调色板', user: '用户', + outline: '大纲', source: '代码', command: '命令', info: '信息', +} + +export const emojiCategories = [ + { id: 'Smileys & Emotion', label: '表情', icon: '😀' }, + { id: 'People & Body', label: '人物', icon: '👋' }, + { id: 'Animals & Nature', label: '自然', icon: '🌿' }, + { id: 'Food & Drink', label: '食物', icon: '🍎' }, + { id: 'Travel & Places', label: '旅行', icon: '🚀' }, + { id: 'Activities', label: '活动', icon: '⚽' }, + { id: 'Objects', label: '物品', icon: '💡' }, + { id: 'Symbols', label: '符号', icon: '❤️' }, + { id: 'Flags', label: '旗帜', icon: '🏳️' }, +] + +// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。 +const keywords: Record = { + '📂': '文件夹 目录 打开', '📁': '文件夹 目录', '📄': '文档 笔记 文章', '📝': '笔记 编辑 记录', + '📃': '文档 文章', '📋': '清单 剪贴板', '📚': '书籍 知识 学习', '📖': '阅读 书籍', '📕': '书籍 红书', + '📗': '书籍 绿书', '📘': '书籍 蓝书', '📙': '书籍 橙书', '📓': '笔记本', '📒': '账本 笔记本', + '📔': '笔记本 日记', '📅': '日历 日期', '🗓️': '日历 计划', '✅': '完成 任务 对勾', '☑️': '勾选 任务', + '⭐': '星星 收藏', '🌟': '星星 重要', '❤️': '爱心 喜欢', '🔥': '火 热门', '💡': '灯泡 灵感 想法', + '📌': '固定 图钉', '🔗': '链接', '🔒': '锁 安全', '🔑': '钥匙 密钥', '⚙️': '设置 齿轮', + '🛠️': '工具 开发', '💻': '电脑 编程 代码', '🖥️': '电脑 显示器', '📱': '手机', '🖼️': '图片 相册', + '🎨': '艺术 设计 调色板', '🎵': '音乐', '🎬': '电影 视频', '📷': '相机 摄影', '🎮': '游戏', + '🏠': '房子 主页 家', '🏡': '家 房子', '🚀': '火箭 项目 发布', '💼': '工作 公文包', '🎯': '目标', + '🌍': '地球 世界', '🌏': '地球 亚洲', '🌱': '幼苗 成长', '🌿': '植物 叶子 自然', '🌸': '花 樱花', + '☀️': '太阳 晴天', '🌙': '月亮 夜晚', '🐱': '猫 动物', '🐶': '狗 动物', '🍎': '苹果 食物', + '☕': '咖啡 饮料', '😀': '笑脸 开心 表情', '😊': '微笑 开心 表情', '🤔': '思考 想法', '👍': '赞 好', + '⚠️': '警告 注意', '❗': '感叹号 重要', '❓': '问号 问题', '💰': '钱 财务', '🎓': '毕业 学习', +} + +export function emojiLabel(emoji: Gemoji): string { + return keywords[emoji.emoji] ?? emoji.description +} + +export function matchesEmoji(emoji: Gemoji, query: string): boolean { + return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, keywords[emoji.emoji] ?? '', emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query) +} diff --git a/web/src/app/treeIcons.test.ts b/web/src/app/treeIcons.test.ts new file mode 100644 index 0000000..3669c74 --- /dev/null +++ b/web/src/app/treeIcons.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import { createApp, nextTick, type App } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Sidebar from './Sidebar.vue' +import { expanded, selectedPaths, treeIconTarget } from './tree' +import * as workspace from './workspace' +import { api } from './api' + +let app: App +let host: HTMLElement +beforeEach(async () => { + vi.spyOn(workspace, 'saveTreeIcon').mockResolvedValue() + vi.spyOn(workspace, 'openNote').mockResolvedValue() + vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true }) + localStorage.removeItem('rawmark.recent-tree-icons') + Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [ + { path: 'folder', dir: true }, { path: 'folder/a.md', dir: false }, + { path: 'folder/b.md', dir: false }, { path: 'folder/c.md', dir: false }, + ].map(e => ({ ...e, size: 0, mtime: 0 })) }) + expanded.clear() + selectedPaths.clear() + treeIconTarget.value = null + host = document.createElement('div') + document.body.append(host) + app = createApp(Sidebar) + app.mount(host) + await nextTick() +}) +afterEach(() => { app.unmount(); host.remove(); vi.restoreAllMocks() }) +const row = (name: string) => [...host.querySelectorAll('.rm-tree-row')].find(e => e.querySelector('.rm-tree-name')?.textContent === name)! +const click = async (element: Element, init: MouseEventInit = {}) => { element.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init })); await nextTick() } +const button = (name: string) => document.querySelector(`[aria-label="${name}"]`)! +async function picker() { + await click(row('folder').querySelector('.rm-tree-icon')!) + await vi.waitFor(() => expect(button('搜索图标')).not.toBeNull()) +} + +describe('文件树图标操作', () => { + it('保留右侧目录菜单,点击默认图标不折叠目录或改变多选', async () => { + expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent).toBe('文件目录') + expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📂') + await click(row('folder')) + expect(row('a').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📄') + await click(row('a'), { ctrlKey: true }) + const selected = [...selectedPaths] + await picker() + expect(expanded.has('folder')).toBe(true) + expect([...selectedPaths]).toEqual(selected) + expect(workspace.openNote).not.toHaveBeenCalled() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await nextTick() + expect(treeIconTarget.value).toBeNull() + expect(document.activeElement).toBe(row('folder').querySelector('.rm-tree-icon')) + }) + + it('中文搜索表情并保存到当前节点,最近使用只记成功操作', async () => { + await picker() + const input = button('搜索图标') as HTMLInputElement + input.value = '文件夹' + input.dispatchEvent(new Event('input', { bubbles: true })) + await vi.waitFor(() => expect(button('文件夹 目录 打开')).not.toBeNull()) + await click(button('文件夹 目录 打开')) + await vi.waitFor(() => expect(treeIconTarget.value).toBeNull()) + expect(workspace.saveTreeIcon).toHaveBeenCalledExactlyOnceWith('folder', { kind: 'emoji', value: '📂' }) + expect(JSON.parse(localStorage.getItem('rawmark.recent-tree-icons')!)).toEqual([{ kind: 'emoji', value: '📂' }]) + }) + + it('保存失败保留弹窗与明确错误,恢复默认成功后关闭', async () => { + vi.mocked(workspace.saveTreeIcon).mockRejectedValueOnce(new Error('磁盘只读')) + await picker() + await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!) + await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain('磁盘只读')) + expect(treeIconTarget.value?.path).toBe('folder') + expect(localStorage.getItem('rawmark.recent-tree-icons')).toBeNull() + await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!) + await vi.waitFor(() => expect(treeIconTarget.value).toBeNull()) + expect(workspace.saveTreeIcon).toHaveBeenLastCalledWith('folder', null) + }) + + it('线性图标可搜索,右键入口选择同一节点', async () => { + row('folder').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) + await nextTick() + await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent === '修改图标')!) + await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent === '线性图标')!) + const input = button('搜索图标') as HTMLInputElement + input.value = '任务'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick() + expect(document.querySelectorAll('.rm-tree-icon-grid button')).toHaveLength(1) + await click(button('任务')) + expect(workspace.saveTreeIcon).toHaveBeenCalledWith('folder', { kind: 'builtin', value: 'task' }) + }) + + it('Ctrl 和 Shift 仍按可见行选择,快速展开/收起保留最新状态', async () => { + await click(row('folder')) + await click(row('a')) + await click(row('c'), { shiftKey: true }) + expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/b.md', 'folder/c.md']) + await click(row('b'), { ctrlKey: true }) + expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/c.md']) + await click(row('folder')) + await click(row('folder')) + await vi.waitFor(() => expect(row('c')).toBeDefined()) + expect(expanded.has('folder')).toBe(true) + await click(row('folder')) + await vi.waitFor(() => expect(row('a')).toBeUndefined()) + expect(selectedPaths.has('folder/a.md')).toBe(false) + }) + + it('从图标按住拖动仍使用整行的拖放契约,不能误开选择框', async () => { + const setData = vi.fn() + const event = new Event('dragstart', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: { setData, effectAllowed: '' } }) + row('folder').querySelector('.rm-tree-icon')!.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(setData).toHaveBeenCalledExactlyOnceWith('application/x-rawmark-path', 'folder') + expect(treeIconTarget.value).toBeNull() + }) + + it('持久属性直接来自目录 API,不维护第二份图标映射', async () => { + workspace.ws.entries[0].icon = { kind: 'builtin', value: 'task' } + await nextTick() + expect(row('folder').querySelector('.rm-tree-icon svg')).not.toBeNull() + workspace.ws.entries[0].icon = { kind: 'emoji', value: '📚' } + await nextTick() + expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📚') + }) +}) + +it('图标请求沿用认证、禁缓存和 null 重置契约', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')) + await api.setTreeIcon('folder/a.md', null) + expect(fetch).toHaveBeenCalledWith('/api/v1/tree-icon', expect.objectContaining({ method: 'PUT', credentials: 'same-origin', cache: 'no-store', body: '{"path":"folder/a.md","icon":null}' })) +}) diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index 633f35a..bb767d0 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -8,7 +8,7 @@ import { editorCommands } from '../editor/shortcuts' import { refreshImages } from '../editor/livepreview' import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from '../editor' import { handleShortcut, registerCommand } from './commands' -import { api, ApiError, encodePath, type Entry } from './api' +import { api, ApiError, encodePath, type Entry, type TreeIcon } from './api' import { drafts } from './drafts' import { refreshMetadata } from './metadata' import { connectEvents, type VaultEvent } from './events' @@ -663,6 +663,12 @@ async function withOp(fn: () => Promise): Promise { } } +export async function saveTreeIcon(path: string, icon: TreeIcon | null) { + await withOp(() => api.setTreeIcon(path, icon)) + try { await refreshTree() } + catch (err) { toast('图标已保存,目录刷新失败:' + errorText(err)) } +} + export async function createNote(dir: string) { const path = uniqueName(dir, '未命名', NOTE_EXT) ws.renaming = path