feat(trash): add bulk purge and automatic retention
This commit is contained in:
1 parent
2b8f18f0db
commit
ac10de0466
16 files changed
+527
-25
No files matched your search
@@ -42,6 +42,7 @@ docker run -d --name rawmark \
|
||||
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
|
||||
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
|
||||
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
|
||||
- 回收站:回收站支持清空全部;默认按原删除时间保留 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/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
|
||||
|
||||
@@ -84,6 +84,12 @@ func run(addr string) error {
|
||||
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
||||
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
||||
srv.FontsDir = filepath.Join(dataDir, "fonts")
|
||||
trashDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(trashDone)
|
||||
srv.RunTrashCleanup(ctx)
|
||||
}()
|
||||
defer func() { stop(); <-trashDone }()
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: srv.Handler(web.Dist()),
|
||||
|
||||
@@ -92,6 +92,7 @@ func (s *Server) routes() []route {
|
||||
{"PUT /api/v1/assets/{path...}", authed, s.replaceImage},
|
||||
{"GET /api/v1/raw/{path...}", authed, s.raw},
|
||||
{"GET /api/v1/trash", authed, s.trashList},
|
||||
{"DELETE /api/v1/trash", authed, s.trashClear},
|
||||
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
|
||||
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
|
||||
{"GET /api/v1/events", authed, s.events},
|
||||
|
||||
@@ -349,14 +349,25 @@
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。",
|
||||
"description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。 trashRetentionDays 为非负安全整数(默认30,0为永不自动删除),null恢复默认。期限适用于已有和新回收站条目,服务启动及每小时按原删除时间永久清理。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"trashRetentionDays": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"default": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +385,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "不是 JSON 对象",
|
||||
"description": "不是 JSON 对象,或自动删除期限无效",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1008,6 +1019,25 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "清空回收站全部条目",
|
||||
"description": "永久删除,不可恢复;结果逐项报告,失败条目保留。只读令牌禁止。",
|
||||
"tags": [
|
||||
"回收站"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "包含成功及失败的逐项结果",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TrashPurgeResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/trash/{id}/restore": {
|
||||
@@ -2366,6 +2396,39 @@
|
||||
"description": "Emoji 字符或内置线性图标名称,最多64字节"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrashPurgeResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deleted",
|
||||
"failed"
|
||||
],
|
||||
"properties": {
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"rawmark/internal/events"
|
||||
)
|
||||
|
||||
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端只做合并与保存
|
||||
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端合并与保存,并校验由服务端执行的清理期限
|
||||
const settingsKey = "app.settings"
|
||||
|
||||
func (s *Server) readSettings() (map[string]json.RawMessage, error) {
|
||||
@@ -60,6 +60,10 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
if _, err := trashRetentionDays(settings); err != nil {
|
||||
writeError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
merged, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSON/JavaScript 共同精确表示的非负整数范围;不乘天数,避免期限溢出。
|
||||
const maxRetentionDays = 1<<53 - 1
|
||||
|
||||
func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) {
|
||||
raw, ok := settings["trashRetentionDays"]
|
||||
if !ok || bytes.Equal(raw, []byte("null")) {
|
||||
return 30, nil
|
||||
}
|
||||
var days int64
|
||||
if err := json.Unmarshal(raw, &days); err != nil || days < 0 || days > maxRetentionDays {
|
||||
return 0, fmt.Errorf("回收站保留天数必须为非负安全整数,0 表示永不自动删除")
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := s.vault.PurgeTrash(0, time.Now())
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) cleanupTrash(now time.Time) error {
|
||||
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。
|
||||
s.settingsMu.Lock()
|
||||
defer s.settingsMu.Unlock()
|
||||
settings, err := s.readSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
days, err := trashRetentionDays(settings)
|
||||
if err != nil || days == 0 {
|
||||
return err
|
||||
}
|
||||
result, err := s.vault.PurgeTrash(days, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(result.Failed) > 0 {
|
||||
return fmt.Errorf("回收站清理部分失败: %v", result.Failed)
|
||||
}
|
||||
if len(result.Deleted) > 0 {
|
||||
slog.Info("已清理过期回收站条目", "count", len(result.Deleted))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunTrashCleanup 启动时执行一次,其后每小时执行;不依赖浏览器在线。
|
||||
func (s *Server) RunTrashCleanup(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.cleanupTrash(time.Now()); err != nil {
|
||||
slog.Error("自动清理回收站失败", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/vault"
|
||||
)
|
||||
|
||||
func TestTrashRetentionSettingsValidation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, body := range []string{`{"trashRetentionDays":-1}`, `{"trashRetentionDays":0.5}`, `{"trashRetentionDays":"30"}`, `{"trashRetentionDays":true}`, `{"trashRetentionDays":9007199254740992}`} {
|
||||
if code := f.status(f.admin, "PATCH", "/api/v1/settings", body, nil); code != 400 {
|
||||
t.Fatalf("应拒绝 %s: %d", body, code)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"30", "0", "9007199254740991", "null"} {
|
||||
if code := f.status(f.admin, "PATCH", "/api/v1/settings", `{"trashRetentionDays":`+value+`}`, nil); code != 200 {
|
||||
t.Fatalf("应接受 %s: %d", value, code)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`{}`, `{"trashRetentionDays":null}`} {
|
||||
settings := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(raw), &settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days, err := trashRetentionDays(settings); err != nil || days != 30 {
|
||||
t.Fatalf("缺省应30天: %d %v", days, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashClearAuthorization(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 scope == "read" {
|
||||
if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 {
|
||||
t.Fatalf("只读不能清空: %d", code)
|
||||
}
|
||||
} else {
|
||||
result := decode[vault.TrashPurgeResult](t, f.do(&http.Client{}, "DELETE", "/api/v1/trash", "", headers))
|
||||
if len(result.Deleted) != 1 || len(result.Failed) != 0 {
|
||||
t.Fatalf("写令牌清空结果异常: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
v, err := vault.Open(f.root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.write("old.md", "content")
|
||||
item, err := v.Delete("old.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metaPath := filepath.Join(f.root, ".rawmark/trash", item.ID, "meta.json")
|
||||
item.DeletedAt = time.Now().Add(-40 * 24 * time.Hour).UnixMilli()
|
||||
meta, _ := json.Marshal(item)
|
||||
if err := os.WriteFile(metaPath, meta, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(v, nil, f.store, f.hub, nil)
|
||||
for _, value := range []string{`{"trashRetentionDays":0}`, `{"trashRetentionDays":-1}`, `{"trashRetentionDays":"broken"}`} {
|
||||
if err := f.store.SetSetting(settingsKey, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := s.cleanupTrash(time.Now())
|
||||
if value != `{"trashRetentionDays":0}` && err == nil {
|
||||
t.Fatal("无效配置不可静默降级为默认删除")
|
||||
}
|
||||
if _, err := os.Stat(metaPath); err != nil {
|
||||
t.Fatalf("禁用或无效设置不应删除: %v", err)
|
||||
}
|
||||
}
|
||||
if err := f.store.SetSetting(settingsKey, `{}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.cleanupTrash(time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("缺省30天应删除旧条目: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrashFailure struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type TrashPurgeResult struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Failed []TrashFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// PurgeTrash 在与恢复相同的写锁内读取元数据并清理。days=0 清空所有;
|
||||
// 自动清理的“永不删除”由调用方处理,不调用本方法。
|
||||
func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
result := TrashPurgeResult{Deleted: []string{}, Failed: []TrashFailure{}}
|
||||
if days < 0 {
|
||||
return result, fmt.Errorf("保留天数不能为负数")
|
||||
}
|
||||
dirs, err := os.ReadDir(v.trash)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
id := dir.Name()
|
||||
if !trashIDPattern.MatchString(id) {
|
||||
continue // 不删除非本程序管理的文件。
|
||||
}
|
||||
item, err := v.readTrashItem(id)
|
||||
if err == nil && days > 0 && (item.DeletedAt > now.UnixMilli() || (now.UnixMilli()-item.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) {
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
err = os.RemoveAll(filepath.Join(v.trash, id))
|
||||
}
|
||||
if err != nil {
|
||||
result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()})
|
||||
} else {
|
||||
result.Deleted = append(result.Deleted, id)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func trashAt(t *testing.T, v *Vault, path string, when time.Time) TrashItem {
|
||||
t.Helper()
|
||||
if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := v.Delete(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := v.readTrashMetadata(item.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.DeletedAt = when.UnixMilli()
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v.trash, item.ID, "meta.json"), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return meta.TrashItem
|
||||
}
|
||||
|
||||
func TestTrashRetentionAndInvalidMetadata(t *testing.T) {
|
||||
v := newVault(t)
|
||||
now := time.Now()
|
||||
old := trashAt(t, v, "old.md", now.Add(-31*24*time.Hour))
|
||||
boundary := trashAt(t, v, "boundary.md", now.Add(-30*24*time.Hour))
|
||||
fresh := trashAt(t, v, "fresh.md", now.Add(-29*24*time.Hour))
|
||||
future := trashAt(t, v, "future.md", now.Add(time.Hour))
|
||||
broken := trashAt(t, v, "broken.md", now.Add(-40*24*time.Hour))
|
||||
if err := os.WriteFile(filepath.Join(v.trash, broken.ID, "meta.json"), []byte(`{"deletedAt":0}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := v.PurgeTrash(30, now)
|
||||
if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 || result.Failed[0].ID != broken.ID {
|
||||
t.Fatalf("结果异常: %+v, %v", result, err)
|
||||
}
|
||||
for _, item := range []TrashItem{old, boundary} {
|
||||
if _, err := os.Stat(filepath.Join(v.trash, item.ID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("过期条目未删除: %v", err)
|
||||
}
|
||||
}
|
||||
for _, item := range []TrashItem{fresh, future, broken} {
|
||||
if _, err := os.Stat(filepath.Join(v.trash, item.ID)); err != nil {
|
||||
t.Fatalf("不应删除未过期/无效条目: %v", err)
|
||||
}
|
||||
}
|
||||
// 极大天数不应溢出成过期期限。
|
||||
result, err = v.PurgeTrash(1<<53-1, now)
|
||||
if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 {
|
||||
t.Fatalf("极大期限: %+v, %v", result, err)
|
||||
}
|
||||
result, err = v.PurgeTrash(0, now)
|
||||
if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 {
|
||||
t.Fatalf("清空全部应独立报告损坏条目: %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashRestoreAndPurgeSerialize(t *testing.T) {
|
||||
for range 20 {
|
||||
v := newVault(t)
|
||||
item := trashAt(t, v, "note.md", time.Now().Add(-40*24*time.Hour))
|
||||
var restoreErr, purgeErr error
|
||||
var result TrashPurgeResult
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); _, restoreErr = v.Restore(item.ID) }()
|
||||
go func() { defer wg.Done(); result, purgeErr = v.PurgeTrash(30, time.Now()) }()
|
||||
wg.Wait()
|
||||
if purgeErr != nil || len(result.Failed) != 0 {
|
||||
t.Fatalf("并发清理失败: %+v %v", result, purgeErr)
|
||||
}
|
||||
if restoreErr == nil {
|
||||
if len(result.Deleted) != 0 {
|
||||
t.Fatal("已恢复的内容不可再次清理")
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(v.Root(), item.Path)); err != nil || string(body) != "content" {
|
||||
t.Fatalf("恢复内容丢失: %v", err)
|
||||
}
|
||||
} else if !errors.Is(restoreErr, ErrNotFound) || len(result.Deleted) != 1 {
|
||||
t.Fatalf("清理先完成时应明确不存在: %v %+v", restoreErr, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,6 +637,9 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
|
||||
}
|
||||
if _, err := CleanPath(metadata.Path); err != nil || metadata.ID != id || metadata.DeletedAt <= 0 || (metadata.Assets != "" && metadata.Assets != filepath.Base(AssetsDir(metadata.Path))) {
|
||||
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据无效", id)
|
||||
}
|
||||
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)
|
||||
@@ -646,6 +649,8 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
|
||||
}
|
||||
|
||||
func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
dirs, err := os.ReadDir(v.trash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -666,6 +671,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||
}
|
||||
|
||||
func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
metadata, err := v.readTrashMetadata(id)
|
||||
item = metadata.TrashItem
|
||||
if err != nil {
|
||||
@@ -675,8 +682,6 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
icons, err := v.readTreeIcons()
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
@@ -741,6 +746,8 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
}
|
||||
|
||||
func (v *Vault) Purge(id string) error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if _, err := v.readTrashItem(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const dialog = ref<HTMLElement>()
|
||||
const content = ref<HTMLElement>()
|
||||
const heading = ref<HTMLElement>()
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts'].includes(section.value))
|
||||
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
|
||||
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
|
||||
|
||||
async function open(id: SettingsSection) {
|
||||
|
||||
+57
-17
@@ -2,33 +2,67 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, type TrashItem } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
import { isImage } from './paths'
|
||||
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
|
||||
import { restore, toast } from './workspace'
|
||||
|
||||
const items = ref<TrashItem[] | null>(null)
|
||||
const failed = ref(false)
|
||||
const error = ref('')
|
||||
const failures = ref<{ id: string; error: string }[]>([])
|
||||
const busy = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
items.value = await api.trash()
|
||||
} catch {
|
||||
failed.value = true
|
||||
error.value = ''
|
||||
} catch (cause) {
|
||||
error.value = '读取回收站失败:' + String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
async function retention(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const days = input.valueAsNumber
|
||||
if (!Number.isSafeInteger(days) || days < 0) {
|
||||
input.value = String(settings.trashRetentionDays)
|
||||
toast('请输入非负整数天数,0 表示永不自动删除')
|
||||
return
|
||||
}
|
||||
if (days === settings.trashRetentionDays) return
|
||||
saving.value = true
|
||||
try { await updateSettings({ trashRetentionDays: days }) }
|
||||
catch (cause) {
|
||||
toast('保存自动删除期限失败:' + String(cause))
|
||||
if (!(cause instanceof SettingsAppearanceError)) await loadSettings().catch(cause => toast('重新读取设置失败:' + String(cause)))
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function doRestore(item: TrashItem) {
|
||||
items.value = items.value!.filter((i) => i.id !== item.id)
|
||||
await restore(item.id)
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try { await restore(item.id); await load() }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function purge(item: TrashItem) {
|
||||
if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return
|
||||
items.value = items.value!.filter((i) => i.id !== item.id)
|
||||
async function purge(item?: TrashItem) {
|
||||
if (busy.value) return
|
||||
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
|
||||
busy.value = true
|
||||
failures.value = []
|
||||
try {
|
||||
await api.purge(item.id)
|
||||
} catch {
|
||||
toast('删除失败')
|
||||
void load()
|
||||
if (item) await api.purge(item.id)
|
||||
else {
|
||||
const result = await api.clearTrash()
|
||||
failures.value = result.failed
|
||||
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
|
||||
}
|
||||
} catch (cause) {
|
||||
toast('删除失败:' + String(cause))
|
||||
} finally {
|
||||
await load()
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,17 +72,23 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="failed" class="rm-tree-empty">读取回收站失败</p>
|
||||
<p v-else-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<SettingRow id="trash-retention" control="rm-trash-retention">
|
||||
<label class="rm-font-input"><input id="rm-trash-retention" type="number" min="0" step="1" :placeholder="String(DEFAULTS.trashRetentionDays)" :value="settings.trashRetentionDays" :disabled="saving" @change="retention" /> 天</label>
|
||||
</SettingRow>
|
||||
<SettingRow id="trash-clear"><button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">清空全部</button></SettingRow>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">{{ error }} <button class="rm-btn" :disabled="busy" @click="load">重试</button></p>
|
||||
<ul v-if="failures.length" role="alert"><li v-for="failure in failures" :key="failure.id">{{ items?.find(item => item.id === failure.id)?.path || failure.id }}:{{ failure.error }}</li></ul>
|
||||
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
|
||||
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">读取中…</p>
|
||||
<ul v-else class="rm-trash-list" :aria-busy="busy">
|
||||
<li v-for="item in items ?? []" :key="item.id">
|
||||
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
|
||||
<div class="rm-trash-info">
|
||||
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
||||
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
||||
</div>
|
||||
<button class="rm-btn" @click="doRestore(item)">恢复</button>
|
||||
<button class="rm-btn rm-btn-danger" @click="purge(item)">彻底删除</button>
|
||||
<button class="rm-btn" :disabled="busy" @click="doRestore(item)">恢复</button>
|
||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">彻底删除</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,11 @@ export interface TrashItem {
|
||||
deletedAt: number
|
||||
}
|
||||
|
||||
export interface TrashPurgeResult {
|
||||
deleted: string[]
|
||||
failed: { id: string; error: string }[]
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string
|
||||
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
|
||||
@@ -216,6 +221,7 @@ export const api = {
|
||||
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
|
||||
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
|
||||
|
||||
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
|
||||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||||
|
||||
@@ -29,6 +29,7 @@ afterEach(() => {
|
||||
cleanup.splice(0).forEach(fn => fn())
|
||||
ws.settings = null
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
|
||||
host = document.createElement('div')
|
||||
@@ -318,3 +319,48 @@ describe('快捷键查找', () => {
|
||||
expect(host!.textContent).toContain('没有匹配的快捷键')
|
||||
})
|
||||
})
|
||||
|
||||
describe('回收站', () => {
|
||||
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
|
||||
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
|
||||
vi.spyOn(api, 'trash').mockResolvedValue([item])
|
||||
let finish!: (value: { deleted: string[]; failed: { id: string; error: string }[] }) => void
|
||||
const clear = vi.spyOn(api, 'clearTrash').mockImplementation(() => new Promise(done => { finish = done }))
|
||||
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValue(true)
|
||||
vi.stubGlobal('confirm', confirm)
|
||||
ws.settings = 'trash'
|
||||
mount(SettingsDialog)
|
||||
await vi.waitFor(() => expect(host!.textContent).toContain('broken'))
|
||||
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-trash-clear button')!
|
||||
button.click()
|
||||
expect(clear).not.toHaveBeenCalled()
|
||||
button.click()
|
||||
await nextTick()
|
||||
expect(confirm).toHaveBeenCalledTimes(2)
|
||||
expect(button.disabled).toBe(true)
|
||||
expect([...host!.querySelectorAll<HTMLButtonElement>('.rm-trash-list button')].every(b => b.disabled)).toBe(true)
|
||||
button.click()
|
||||
expect(clear).toHaveBeenCalledTimes(1)
|
||||
finish({ deleted: [], failed: [{ id: item.id, error: '元数据损坏' }] })
|
||||
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('元数据损坏'))
|
||||
expect(api.trash).toHaveBeenCalledTimes(2)
|
||||
expect(button.disabled).toBe(false)
|
||||
})
|
||||
it('保留天数支持0,拒绝负数和小数,不提交无效值', async () => {
|
||||
vi.spyOn(api, 'trash').mockResolvedValue([])
|
||||
ws.settings = 'trash'
|
||||
mount(SettingsDialog)
|
||||
const input = host!.querySelector<HTMLInputElement>('#rm-trash-retention')!
|
||||
expect(input.value).toBe('30')
|
||||
for (const value of ['-1', '1.5', '']) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
expect(api.patchSettings).not.toHaveBeenCalled()
|
||||
expect(input.value).toBe('30')
|
||||
}
|
||||
input.value = '0'
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledWith({ trashRetentionDays: 0 }))
|
||||
expect(settings.trashRetentionDays).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ export const CONTENT_WIDTHS = [
|
||||
|
||||
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
|
||||
|
||||
export const DEFAULTS = { hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
|
||||
export const DEFAULTS = { trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
|
||||
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
|
||||
|
||||
const CACHE_KEY = 'rawmark.settings'
|
||||
@@ -39,6 +39,7 @@ function sanitize(raw: Record<string, unknown>): Settings {
|
||||
const hiddenFonts = Array.isArray(raw.hiddenFonts) ? raw.hiddenFonts.filter((id): id is string => typeof id === 'string' && id !== 'maple' && Object.hasOwn(FONT_PRESETS, id)) : []
|
||||
return {
|
||||
hiddenFonts,
|
||||
trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays,
|
||||
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
|
||||
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
|
||||
shortcuts: sanitizeShortcuts(raw.shortcuts),
|
||||
|
||||
@@ -13,6 +13,8 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
|
||||
]
|
||||
|
||||
export const SETTING_ITEMS = {
|
||||
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
|
||||
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
|
||||
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
|
||||
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
|
||||
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
|
||||
|
||||
Reference in new issue
Block a user