diff --git a/README.md b/README.md index 50e8dc7..641c471 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ docker run -d --name rawmark \ - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。 -- 回收站:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。 +- 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「应用 → 存储空间」按需统计数据目录与外置笔记库,不跟随内部符号链接;附件与回收站包含在笔记库总量中。 - 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。 - 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。 - 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 636a85c..b40b98a 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -81,6 +81,7 @@ func run(addr string) error { go cleanupSessions(ctx, st) srv := api.New(v, am, st, hub, idx) + srv.DataDir = dataDir srv.ThemesDir = filepath.Join(dataDir, "themes") srv.PluginsDir = filepath.Join(dataDir, "plugins") srv.FontsDir = filepath.Join(dataDir, "fonts") diff --git a/internal/api/api.go b/internal/api/api.go index 0ca28da..324d659 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -38,6 +38,7 @@ type Server struct { // 串行化设置的"读取-合并-写入" settingsMu sync.Mutex // ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。 + DataDir string ThemesDir string PluginsDir string FontsDir string @@ -93,6 +94,7 @@ func (s *Server) routes() []route { {"GET /api/v1/raw/{path...}", authed, s.raw}, {"GET /api/v1/trash", authed, s.trashList}, {"DELETE /api/v1/trash", authed, s.trashClear}, + {"GET /api/v1/storage", sessionOnly, s.getStorage}, {"POST /api/v1/trash/{id}/restore", authed, s.trashRestore}, {"DELETE /api/v1/trash/{id}", authed, s.trashPurge}, {"GET /api/v1/events", authed, s.events}, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7d9218f..f88347e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -128,6 +128,7 @@ func newFixture(t *testing.T) *fixture { t.Cleanup(func() { idx.Close() }) hub := events.NewHub() server := New(v, am, st, hub, idx) + server.DataDir = dir server.ThemesDir = filepath.Join(dir, "themes") server.PluginsDir = filepath.Join(dir, "plugins") server.FontsDir = filepath.Join(dir, "fonts") diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 551a56c..cbe4170 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -2185,6 +2185,38 @@ } } } + }, + "/storage": { + "get": { + "summary": "按需统计存储空间", + "description": "只接受登录会话。统计数据目录和实际笔记库的普通文件字节,嵌套去重,不跟随内部符号链接;附件/回收站是笔记库的子项,totalSize不重复累加。calculatedAt为统计完成时间(Unix毫秒)。扫描失败返回错误,不返回部分结果。", + "tags": [ + "设置" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "统计结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageUsage" + } + } + } + }, + "403": { + "description": "只接受登录会话" + }, + "500": { + "description": "扫描失败" + } + } + } } }, "components": { @@ -2429,6 +2461,61 @@ } } } + }, + "StorageUsage": { + "type": "object", + "required": [ + "totalSize", + "categories", + "assetsSize", + "trashSize", + "calculatedAt" + ], + "properties": { + "totalSize": { + "type": "integer", + "minimum": 0 + }, + "assetsSize": { + "type": "integer", + "minimum": 0 + }, + "trashSize": { + "type": "integer", + "minimum": 0 + }, + "calculatedAt": { + "type": "integer", + "minimum": 0 + }, + "categories": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "size" + ], + "properties": { + "id": { + "type": "string", + "enum": [ + "vault", + "databases", + "fonts", + "themes", + "plugins", + "other" + ] + }, + "size": { + "type": "integer", + "minimum": 0 + } + } + } + } + } } } } diff --git a/internal/api/storage.go b/internal/api/storage.go new file mode 100644 index 0000000..7199e5f --- /dev/null +++ b/internal/api/storage.go @@ -0,0 +1,137 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net/http" + "path/filepath" + "strings" + "time" +) + +type storageCategory struct { + ID string `json:"id"` + Size int64 `json:"size"` +} + +type storageUsage struct { + TotalSize int64 `json:"totalSize"` + Categories []storageCategory `json:"categories"` + AssetsSize int64 `json:"assetsSize"` + TrashSize int64 `json:"trashSize"` + CalculatedAt int64 `json:"calculatedAt"` +} + +func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) { + usage, err := scanStorage(r.Context(), s.DataDir, s.vault.Root()) + if err != nil { + internalError(w, r, err) + return + } + writeJSON(w, http.StatusOK, usage) +} + +// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。 +// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。 +func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) { + usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}} + if dataDir == "" { + return usage, fmt.Errorf("未配置应用数据目录") + } + dataRoot, err := filepath.EvalSymlinks(dataDir) + if err != nil { + return usage, err + } + dataRoot, err = filepath.Abs(dataRoot) + if err != nil { + return usage, err + } + vaultRoot, err := filepath.Abs(vaultDir) + if err != nil { + return usage, err + } + appCategory := func(rel string) int { + top := strings.Split(filepath.ToSlash(rel), "/")[0] + switch top { + case "rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm": + return 1 + case "fonts": + return 2 + case "themes": + return 3 + case "plugins": + return 4 + default: + return 5 + } + } + walk := func(root, skip string, isVault bool) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if err := ctx.Err(); err != nil { + return err + } + if walkErr != nil { + if path != root && errors.Is(walkErr, fs.ErrNotExist) { + return nil // 文件可能在扫描期间被移动或删除。 + } + return walkErr + } + if path != root && path == skip && entry.IsDir() { + return filepath.SkipDir + } + if entry.IsDir() || entry.Type()&fs.ModeSymlink != 0 { + return nil + } + info, err := entry.Info() + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + category := 0 + if !isVault || dataRoot == vaultRoot { + category = appCategory(rel) + if isVault && category == 5 { + category = 0 + } + } + size := info.Size() + usage.TotalSize += size + usage.Categories[category].Size += size + if category == 0 { + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) >= 3 && parts[0] == ".rawmark" && parts[1] == "trash" { + usage.TrashSize += size + } else { + for _, part := range parts[:len(parts)-1] { + if part == "assets" || strings.HasSuffix(part, ".assets") { + usage.AssetsSize += size + break + } + } + } + } + return nil + }) + } + if dataRoot != vaultRoot { + if err := walk(dataRoot, vaultRoot, false); err != nil { + return storageUsage{}, err + } + } + if err := walk(vaultRoot, dataRoot, true); err != nil { + return storageUsage{}, err + } + usage.CalculatedAt = time.Now().UnixMilli() + return usage, nil +} diff --git a/internal/api/storage_test.go b/internal/api/storage_test.go new file mode 100644 index 0000000..e125c3f --- /dev/null +++ b/internal/api/storage_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestStorageCountsOwnedRootsOnce(t *testing.T) { + for _, layout := range []string{"nested-vault", "external-vault", "nested-data", "same-root"} { + t.Run(layout, func(t *testing.T) { + root := t.TempDir() + data, vault := filepath.Join(root, "data"), filepath.Join(root, "data", "vault") + switch layout { + case "external-vault": + vault = filepath.Join(root, "external") + case "nested-data": + vault, data = root, filepath.Join(root, "app") + case "same-root": + vault = data + } + write := func(base, rel string, size int) { + t.Helper() + path := filepath.Join(base, rel) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, make([]byte, size), 0600); err != nil { + t.Fatal(err) + } + } + write(vault, "note.md", 5) + write(vault, "dir/assets/a.png", 7) + write(vault, "old.assets/b.png", 11) + write(vault, ".rawmark/trash/id/meta.json", 13) + write(vault, ".rawmark/trash/id/assets/a.png", 17) + write(data, "rawmark.db", 19) + write(data, "index.db-wal", 23) + write(data, "fonts/a/file.woff2", 29) + write(data, "themes/a/theme.css", 31) + write(data, "plugins/a/main.js", 37) + write(data, "misc.bin", 41) + outside := t.TempDir() + write(outside, "outside.md", 1000) + if err := os.Symlink(outside, filepath.Join(vault, "link")); err != nil { + t.Fatal(err) + } + usage, err := scanStorage(context.Background(), data, vault) + if err != nil { + t.Fatal(err) + } + want := map[string]int64{"vault": 53, "databases": 42, "fonts": 29, "themes": 31, "plugins": 37, "other": 41} + if layout == "same-root" { + want["vault"] += 41 + want["other"] = 0 + } + for _, category := range usage.Categories { + if category.Size != want[category.ID] { + t.Errorf("%s = %d, want %d", category.ID, category.Size, want[category.ID]) + } + } + if usage.TotalSize != 233 || usage.AssetsSize != 18 || usage.TrashSize != 30 || usage.CalculatedAt <= 0 { + t.Fatalf("总量或子项不符: %+v", usage) + } + }) + } +} + +func TestStorageDoesNotReturnPartialSuccess(t *testing.T) { + root := t.TempDir() + if _, err := scanStorage(context.Background(), root, filepath.Join(root, "missing")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("仓库缺失应报错: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := scanStorage(ctx, root, root); !errors.Is(err, context.Canceled) { + t.Fatalf("请求取消应停止扫描: %v", err) + } +} diff --git a/internal/api/trash_test.go b/internal/api/trash_test.go index e853259..fe2c7e1 100644 --- a/internal/api/trash_test.go +++ b/internal/api/trash_test.go @@ -34,13 +34,16 @@ func TestTrashRetentionSettingsValidation(t *testing.T) { } } -func TestTrashClearAuthorization(t *testing.T) { +func TestStorageAndTrashClearAuthorization(t *testing.T) { f := newFixture(t) f.write("a.md", "content") decode[vault.TrashItem](t, f.do(f.admin, "DELETE", "/api/v1/files/a.md", "", nil)) for _, scope := range []string{"read", "write"} { token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"test","scope":"`+scope+`"}`, nil)).Token headers := map[string]string{"Authorization": "Bearer " + token} + if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", headers); code != 403 { + t.Fatalf("存储统计仅接受会话: %d", code) + } if scope == "read" { if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 { t.Fatalf("只读不能清空: %d", code) @@ -52,6 +55,13 @@ func TestTrashClearAuthorization(t *testing.T) { } } } + usage := decode[storageUsage](t, f.do(f.admin, "GET", "/api/v1/storage", "", nil)) + if usage.TotalSize <= 0 || len(usage.Categories) != 6 { + t.Fatalf("会话统计: %+v", usage) + } + if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", nil); code != 401 { + t.Fatalf("匿名不能查看: %d", code) + } } func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) { diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue index 4b48164..444977d 100644 --- a/web/src/app/SettingsDialog.vue +++ b/web/src/app/SettingsDialog.vue @@ -14,6 +14,7 @@ import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './s import ShortcutSettings from './ShortcutSettings.vue' import TokensPanel from './TokensPanel.vue' import TrashPanel from './TrashPanel.vue' +import StoragePanel from './StoragePanel.vue' import { toast, ws, type SettingsSection } from './workspace' const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector('meta[name="rawmark-version"]')?.content @@ -204,6 +205,7 @@ async function changePassword() { +