feat(i18n): add English and Simplified Chinese

This commit is contained in:
eezd committed 2026-10-26 22:04:33 +08:00
1 parent 38c4c8b781
commit 1e9e0678ee
149 files changed
+5347 -1135

No files matched your search

+6
View File
@@ -89,6 +89,12 @@ RawMark 是单用户 Markdown 网页笔记编辑器。文件是真源;Go 标
- 模块内部优先相对导入,跨职责使用 `@`(`web/src`)和 `@@`(`web/src/common`)。Vue 组件使用 PascalCase,其他文件使用 kebab-case;SFC 使用 Composition API 与 TypeScript。既有编辑器主题、全局 CSS 和专用 DOM 控件按业务保留,普通表单优先 Element Plus。 - 模块内部优先相对导入,跨职责使用 `@`(`web/src`)和 `@@`(`web/src/common`)。Vue 组件使用 PascalCase,其他文件使用 kebab-case;SFC 使用 Composition API 与 TypeScript。既有编辑器主题、全局 CSS 和专用 DOM 控件按业务保留,普通表单优先 Element Plus。
- 外部未知数据使用 unknown,在实际边界验证;不以 any、类型忽略或假数据掩盖契约。沿用 ESLint/Antfu,不增加另一套格式化工具。 - 外部未知数据使用 unknown,在实际边界验证;不以 any、类型忽略或假数据掩盖契约。沿用 ESLint/Antfu,不增加另一套格式化工具。
## 多语言
- 使用 Vue I18n Composition API,默认英文 `en`、可选简体中文 `zh-CN`;语言存于现有账户设置,登录页选择仅作本机临时显示,登录后以服务端设置覆盖。
- 界面文案使用稳定消息键;公共字典放 `common/i18n/`,页面私有字典随模块,由应用入口装配,公共代码不得反向导入页面。命令、设置搜索和编辑器 DOM 控件需支持即时切换,不重建正文或丢失编辑历史。用户内容、文件名及插件自带文字不翻译。
- Axios 发送 `Accept-Language`,API 返回 `Content-Language`;默认英文,状态码、业务码和响应形状不随语言改变。公开错误保留消息 ID、参数和原始错误链,在 HTTP 边界生成译文,不匹配已翻译文字判断错误。
## 认证与接口契约 ## 认证与接口契约
- 登录是密码加后端六位图片验证码,首次初始化设置密码;无用户名、注册或角色系统。验证码有效五分钟,绑定挑战及来源,每次登录提交一次性消费,失败必须刷新;保留签发和登录限流。 - 登录是密码加后端六位图片验证码,首次初始化设置密码;无用户名、注册或角色系统。验证码有效五分钟,绑定挑战及来源,每次登录提交一次性消费,失败必须刷新;保留签发和登录限流。
+1 -1
View File
@@ -29,7 +29,7 @@ Notes are ordinary `.md` files, with images stored in an `assets/` folder alongs
- **Keep things organized.** Folders, tags, full-text search, and document outlines, with drag-and-drop moves, bulk actions, and custom file icons. - **Keep things organized.** Folders, tags, full-text search, and document outlines, with drag-and-drop moves, bulk actions, and custom file icons.
- **Continue where you left off.** Multiple tabs and pinned notes survive refreshes and browser restarts. Changes save automatically, and external file edits sync to the page. - **Continue where you left off.** Multiple tabs and pinned notes survive refreshes and browser restarts. Changes save automatically, and external file edits sync to the page.
- **Keep images with your notes.** Paste, upload, or import images in bulk. View references, rename, move, replace, and clean up unreferenced images in the image manager. - **Keep images with your notes.** Paste, upload, or import images in bulk. View references, rename, move, replace, and clean up unreferenced images in the image manager.
- **Make it feel familiar.** Light and dark modes, separate interface, text, and code fonts, font imports, customizable shortcuts, themes, and CSS snippets. - **Make it feel familiar.** English and Simplified Chinese, light and dark modes, separate interface, text, and code fonts, font imports, customizable shortcuts, themes, and CSS snippets.
- **Take your data with you.** Import Markdown or ZIP archives and export notes with attachments. Use the recycle bin, full-instance backups, settings migration, API automation, and frontend plugins. - **Take your data with you.** Import Markdown or ZIP archives and export notes with attachments. Use the recycle bin, full-instance backups, settings migration, API automation, and frontend plugins.
RawMark is built for **single-user, self-hosted use**. It currently does not offer collaboration or public sharing. RawMark is built for **single-user, self-hosted use**. It currently does not offer collaboration or public sharing.
+1 -1
View File
@@ -29,7 +29,7 @@ RawMark 适合想把笔记保存在自己服务器上的个人用户:整理知
- **有条理地整理**:文件夹、标签、全文搜索与文章大纲;拖动移动、批量操作、自定义文件图标,让笔记各归其位。 - **有条理地整理**:文件夹、标签、全文搜索与文章大纲;拖动移动、批量操作、自定义文件图标,让笔记各归其位。
- **延续工作状态**:多标签页与固定标签,刷新或重启后恢复;自动保存,外部文件修改同步到页面。 - **延续工作状态**:多标签页与固定标签,刷新或重启后恢复;自动保存,外部文件修改同步到页面。
- **让图片跟着笔记走**:粘贴、上传与批量导入图片;图片管理支持引用查看、重命名、移动、替换及孤立图片清理。 - **让图片跟着笔记走**:粘贴、上传与批量导入图片;图片管理支持引用查看、重命名、移动、替换及孤立图片清理。
- **按自己的习惯调整**:浅色与深色模式,独立的界面、正文及代码字体,可导入字体、自定义快捷键、主题和 CSS 片段。 - **按自己的习惯调整**:英文与简体中文、浅色与深色模式,独立的界面、正文及代码字体,可导入字体、自定义快捷键、主题和 CSS 片段。
- **数据可带走**:导入 Markdown 或 ZIP,导出笔记与附件;回收站、完整实例备份与设置迁移,支持 API 自动化及前端插件扩展。 - **数据可带走**:导入 Markdown 或 ZIP,导出笔记与附件;回收站、完整实例备份与设置迁移,支持 API 自动化及前端插件扩展。
RawMark 面向**单用户、自托管**场景。目前不提供多人协作或公开分享。 RawMark 面向**单用户、自托管**场景。目前不提供多人协作或公开分享。
+7 -1
View File
@@ -27,7 +27,7 @@ curl --fail-with-body \
{ {
"code": 0, "code": 0,
"data": { "ok": true }, "data": { "ok": true },
"message": "成功" "message": "Success"
} }
``` ```
@@ -45,6 +45,12 @@ curl --fail-with-body \
**文件原文、下载、WebSocket、204 及 OpenAPI 文档不包 JSON 外壳**。`GET /files/{path}` 返回 Markdown 原文和 `ETag`;`PUT /files/{path}` 接收原文,成功返回包含 `rev` 的 JSON。用 `If-Match: "rev"` 防止覆盖新版本,或 `If-None-Match: *` 要求目标不存在;都不带会直接覆盖,不适合需要冲突保护的写入。 **文件原文、下载、WebSocket、204 及 OpenAPI 文档不包 JSON 外壳**。`GET /files/{path}` 返回 Markdown 原文和 `ETag`;`PUT /files/{path}` 接收原文,成功返回包含 `rev` 的 JSON。用 `If-Match: "rev"` 防止覆盖新版本,或 `If-None-Match: *` 要求目标不存在;都不带会直接覆盖,不适合需要冲突保护的写入。
## 响应语言
发送 `Accept-Language: en` 或 `Accept-Language: zh-CN` 选择应用提示的语言,未指定或不支持的语言默认英文。服务端按质量权重选择支持的语言,并在 API 响应的 `Content-Language` 中返回实际语言;API 继续 `Cache-Control: no-store`。`message`、导入警告、维护状态及清理跳过原因会本地化,HTTP 状态、业务码、字段、笔记正文及用户资源名称保持不变。客户端应按状态和 `code` 判断结果,不能匹配翻译后的文案。
账户语言位于 `/settings` 的 `locale` 字段,仅支持 `en`、`zh-CN`;PATCH 传 `null` 删除覆盖,恢复默认英文。该字段与其他设置一同导出、导入和同步;登录页的临时选择不写入账户。请求头只影响当前响应,不修改账户语言。
## 浏览器登录 ## 浏览器登录
实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。 实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。
+8
View File
@@ -97,3 +97,11 @@ python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip
先阅读 [AGENTS.md](../AGENTS.md),聚焦实际需求,保留必要契约,不机械照搬模板示例。前端目录以 [vue3-starter 固定基线](https://github.com/eezd/vue3-starter/tree/fd41d62ce3f1c8338a478e0be3e2f31b53b7ea5e) 为参考,RawMark 的编辑器、主题和专用交互按业务保留。 先阅读 [AGENTS.md](../AGENTS.md),聚焦实际需求,保留必要契约,不机械照搬模板示例。前端目录以 [vue3-starter 固定基线](https://github.com/eezd/vue3-starter/tree/fd41d62ce3f1c8338a478e0be3e2f31b53b7ea5e) 为参考,RawMark 的编辑器、主题和专用交互按业务保留。
提交前运行适用检查,说明实际验证与未验证范围。Commit 使用清晰英文,按功能拆分;不要提交真实笔记、账户数据、密钥或本地交接文件。 提交前运行适用检查,说明实际验证与未验证范围。Commit 使用清晰英文,按功能拆分;不要提交真实笔记、账户数据、密钥或本地交接文件。
## 多语言
使用 Vue I18n Composition API,支持 `en` 与 `zh-CN`,默认英文。公共字典位于 `web/src/common/i18n/`,模块私有字典位于 `pages/<module>/locales/`,应用 `src/i18n.ts` 装配字典;不要用中文原文作为运行时查找键,也不要从公共模块导入页面字典。模板及命令、设置搜索等长期对象在使用时翻译;日期、数字和排序读取当前语言。
账户语言沿用 settings store 的串行写入、服务器设置与事件同步;登录页仅更新本机启动缓存,认证后读取服务器值。CodeMirror 使用 locale Compartment 更新控件,不替换文档、选区或撤销历史。用户正文、现有路径和插件提供的文字保持原样。
后端 `internal/i18n/` 保存消息 ID 与参数,错误在 HTTP 边界根据 `Accept-Language` 渲染,并保留 `errors.Is/As` 及原始诊断;日志遵循原有语言。既有索引警告只在需要时重新分析源笔记生成译文,同时更新该条目的完整元数据和版本,不改索引格式;已删除或移动的过期条目跳过,其他读取错误明确返回。新增文案同时补充中英字典与参数,运行字典完整性、设置同步、编辑器和 API 的相应测试。
+4
View File
@@ -65,3 +65,7 @@ ZIP 会导入到同名新文件夹,保留笔记层级。本地图片归入各
插件由实例管理者安装,属于有完整前端权限的扩展,请只安装可信来源;不提供沙箱隔离保证。开发入口见[开发指南](development.md#主题字体与插件)。 插件由实例管理者安装,属于有完整前端权限的扩展,请只安装可信来源;不提供沙箱隔离保证。开发入口见[开发指南](development.md#主题字体与插件)。
插件导致界面异常时,可在地址后加 `?disable-plugins` 暂停插件并进入管理。主题或 CSS 片段导致界面不可用时,可加 `?reset-appearance` 临时停用自定义外观,再进入设置恢复默认。这些入口不自动删除笔记或丢弃编辑内容。 插件导致界面异常时,可在地址后加 `?disable-plugins` 暂停插件并进入管理。主题或 CSS 片段导致界面不可用时,可加 `?reset-appearance` 临时停用自定义外观,再进入设置恢复默认。这些入口不自动删除笔记或丢弃编辑内容。
## 界面语言
默认使用英文。在 **Settings → Appearance → Language** 选择 **简体中文**;中文界面对应「设置 → 外观 → 语言」。更改立即生效并保存在账户设置中,所有设备共用,不改写笔记内容或已有文件名。登录页也可以临时切换语言,登录后以账户偏好为准。
+49 -24
View File
@@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"path" "path"
"rawmark/internal/backup" "rawmark/internal/backup"
"rawmark/internal/i18n"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -162,9 +163,11 @@ func (s *Server) Handler(static fs.FS) http.Handler {
} }
mux.Handle(rt.pattern, h) mux.Handle(rt.pattern, h)
} }
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") }) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, 404, message(w, "api_endpoint_not_found_c49d86"))
})
mux.Handle("/", newStaticHandler(static)) mux.Handle("/", newStaticHandler(static))
return securityHeaders(sameOrigin(s.guardAPI(mux))) return localized(securityHeaders(sameOrigin(s.guardAPI(mux))))
} }
// ---- 中间件 ---- // ---- 中间件 ----
@@ -189,7 +192,7 @@ func sameOrigin(next http.Handler) http.Handler {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
if origin := r.Header.Get("Origin"); origin != "" { if origin := r.Header.Get("Origin"); origin != "" {
if host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"); host != r.Host { if host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"); host != r.Host {
writeError(w, 403, "跨站请求被拒绝") writeError(w, 403, message(w, "cross_site_request_rejected_5e7a50"))
return return
} }
} }
@@ -216,9 +219,9 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
} }
switch err := s.auth.Setup(body.Password); { switch err := s.auth.Setup(body.Password); {
case errors.Is(err, auth.ErrWeakPassword): case errors.Is(err, auth.ErrWeakPassword):
writeError(w, 400, err.Error()) writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, auth.ErrAlreadySetUp): case errors.Is(err, auth.ErrAlreadySetUp):
writeError(w, 409, err.Error()) writeError(w, 409, i18n.RenderError(err, responseLocale(w)))
case err != nil: case err != nil:
internalError(w, r, err) internalError(w, r, err)
default: default:
@@ -242,13 +245,13 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password s
token, err := s.auth.AttemptLogin(clientIP(r), password, challenge) token, err := s.auth.AttemptLogin(clientIP(r), password, challenge)
switch { switch {
case errors.Is(err, auth.ErrRateLimited): case errors.Is(err, auth.ErrRateLimited):
writeError(w, 429, err.Error()) writeError(w, 429, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, errCaptchaInvalid): case errors.Is(err, errCaptchaInvalid):
writeFailure(w, 400, CodeCaptchaInvalid, err.Error(), nil) writeFailure(w, 400, CodeCaptchaInvalid, i18n.RenderError(err, responseLocale(w)), nil)
case errors.Is(err, errCaptchaExpired): case errors.Is(err, errCaptchaExpired):
writeFailure(w, 400, CodeCaptchaExpired, err.Error(), nil) writeFailure(w, 400, CodeCaptchaExpired, i18n.RenderError(err, responseLocale(w)), nil)
case errors.Is(err, auth.ErrBadCredentials): case errors.Is(err, auth.ErrBadCredentials):
writeFailure(w, 401, CodeBadCredentials, err.Error(), nil) writeFailure(w, 401, CodeBadCredentials, i18n.RenderError(err, responseLocale(w)), nil)
case err != nil: case err != nil:
internalError(w, r, err) internalError(w, r, err)
default: default:
@@ -294,7 +297,7 @@ func (s *Server) setTreeIcon(w http.ResponseWriter, r *http.Request) {
} }
var icon *vault.TreeIcon var icon *vault.TreeIcon
if len(body.Icon) == 0 || json.Unmarshal(body.Icon, &icon) != nil { if len(body.Icon) == 0 || json.Unmarshal(body.Icon, &icon) != nil {
writeError(w, 400, "请求格式错误:需要 icon(null 恢复默认)") writeError(w, 400, message(w, "invalid_request_icon_is_required_16149e"))
return return
} }
if err := s.vault.SetTreeIcon(body.Path, icon); err != nil { if err := s.vault.SetTreeIcon(body.Path, icon); err != nil {
@@ -332,7 +335,7 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) { func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes)) data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
if err != nil { if err != nil {
writeError(w, 413, "笔记过大") writeError(w, 413, message(w, "note_is_too_large_86d890"))
return return
} }
pre := vault.Precondition{ pre := vault.Precondition{
@@ -394,14 +397,14 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
writeError(w, 400, "缺少上传文件") writeError(w, 400, message(w, "upload_file_is_missing_a2be00"))
return return
} }
defer file.Close() defer file.Close()
var link string var link string
if r.URL.Query().Has("folder") { if r.URL.Query().Has("folder") {
if r.URL.Query().Has("note") { if r.URL.Query().Has("note") {
writeError(w, 400, "不能同时指定笔记和文件夹") writeError(w, 400, message(w, "a_note_and_a_folder_8b62b5"))
return return
} }
link, err = s.vault.SaveFolderAsset(r.URL.Query().Get("folder"), header.Filename, file, maxAssetBytes) link, err = s.vault.SaveFolderAsset(r.URL.Query().Get("folder"), header.Filename, file, maxAssetBytes)
@@ -421,16 +424,16 @@ func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil { if err := r.ParseMultipartForm(1 << 20); err != nil {
var tooLarge *http.MaxBytesError var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { if errors.As(err, &tooLarge) {
writeError(w, 413, "图片不能超过 50 MB") writeError(w, 413, message(w, "images_must_not_exceed_mb_61d721"))
} else { } else {
writeError(w, 400, "无法读取上传文件") writeError(w, 400, message(w, "could_not_read_uploaded_file_4f04cc"))
} }
return return
} }
defer r.MultipartForm.RemoveAll() defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
writeError(w, 400, "缺少上传文件") writeError(w, 400, message(w, "upload_file_is_missing_a2be00"))
return return
} }
defer file.Close() defer file.Close()
@@ -504,7 +507,7 @@ func clientIP(r *http.Request) string {
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil {
writeError(w, 400, "请求格式错误") writeError(w, 400, message(w, "invalid_request_format_2529b1"))
return false return false
} }
return true return true
@@ -512,22 +515,22 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
func internalError(w http.ResponseWriter, r *http.Request, err error) { func internalError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("请求处理失败", "method", r.Method, "path", logPath(r.URL.Path), "err", err) slog.Error("请求处理失败", "method", r.Method, "path", logPath(r.URL.Path), "err", err)
writeError(w, 500, "服务器内部错误") writeError(w, 500, message(w, "internal_server_error_036549"))
} }
func vaultError(w http.ResponseWriter, r *http.Request, err error) { func vaultError(w http.ResponseWriter, r *http.Request, err error) {
var conflict *vault.ConflictError var conflict *vault.ConflictError
switch { switch {
case errors.As(err, &conflict): case errors.As(err, &conflict):
writeFailure(w, 412, CodeConflict, err.Error(), map[string]string{"rev": conflict.Rev}) writeFailure(w, 412, CodeConflict, message(w, "version_conflict"), map[string]string{"rev": conflict.Rev})
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon): case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon):
writeError(w, 400, err.Error()) writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, vault.ErrNotFound): case errors.Is(err, vault.ErrNotFound):
writeError(w, 404, err.Error()) writeError(w, 404, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, vault.ErrExists): case errors.Is(err, vault.ErrExists):
writeError(w, 409, err.Error()) writeError(w, 409, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, vault.ErrTooLarge): case errors.Is(err, vault.ErrTooLarge):
writeError(w, 413, err.Error()) writeError(w, 413, i18n.RenderError(err, responseLocale(w)))
default: default:
internalError(w, r, err) internalError(w, r, err)
} }
@@ -539,5 +542,27 @@ func (s *Server) metadata(w http.ResponseWriter, r *http.Request) {
internalError(w, r, err) internalError(w, r, err)
return return
} }
writeJSON(w, 200, map[string]any{"notes": notes, "ready": ready}) // Older derived indexes store rendered warnings. Reanalyze only affected notes
// and keep each returned note at one revision while the index catches up.
currentNotes := notes[:0]
for _, note := range notes {
if len(note.Warnings) > 0 {
content, rev, err := s.vault.Read(note.Path)
if errors.Is(err, vault.ErrNotFound) {
continue
}
if err != nil {
vaultError(w, r, err)
return
}
note.Metadata = vault.Analyze(note.Path, content)
note.Rev = rev
note.Warnings = []string{}
for _, warning := range note.Diagnostics {
note.Warnings = append(note.Warnings, i18n.RenderError(warning, responseLocale(w)))
}
}
currentNotes = append(currentNotes, note)
}
writeJSON(w, 200, map[string]any{"notes": currentNotes, "ready": ready})
} }
+5 -1
View File
@@ -1,6 +1,7 @@
package api package api
import "net/http" import "net/http"
import "rawmark/internal/i18n"
func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
@@ -10,7 +11,7 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(body.Paths) == 0 || len(body.Paths) > 1000 { if len(body.Paths) == 0 || len(body.Paths) > 1000 {
writeError(w, 400, "请选择 1~1000 张待清理图片") writeError(w, 400, message(w, "select_between_and_images_to_338e4e"))
return return
} }
result, err := s.vault.CleanupImages(body.Paths) result, err := s.vault.CleanupImages(body.Paths)
@@ -18,5 +19,8 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err) vaultError(w, r, err)
return return
} }
for i := range result.Skipped {
result.Skipped[i].Reason = i18n.RenderError(result.Skipped[i].Cause, responseLocale(w))
}
writeJSON(w, 200, result) writeJSON(w, 200, result)
} }
+1 -1
View File
@@ -34,7 +34,7 @@ func TestImageCleanupRejectsDirectories(t *testing.T) {
t.Fatalf("清理应逐项汇报:%d", res.StatusCode) t.Fatalf("清理应逐项汇报:%d", res.StatusCode)
} }
result := decode[vault.CleanupResult](t, res) result := decode[vault.CleanupResult](t, res)
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "不是图片文件" { if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "Not an image file" {
t.Fatalf("目录应跳过,普通孤立图片仍可清理:%+v", result) t.Fatalf("目录应跳过,普通孤立图片仍可清理:%+v", result)
} }
kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil) kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil)
+4 -4
View File
@@ -33,7 +33,7 @@ func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler {
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
token, ok := strings.CutPrefix(header, "Bearer ") token, ok := strings.CutPrefix(header, "Bearer ")
if !ok || token == "" || strings.ContainsAny(token, " \t\r\n") { if !ok || token == "" || strings.ContainsAny(token, " \t\r\n") {
writeError(w, 401, "请先登录") writeError(w, 401, message(w, "please_sign_in_48144f"))
return return
} }
p := principal{Token: token} p := principal{Token: token}
@@ -50,16 +50,16 @@ func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler {
return return
} }
if !valid { if !valid {
writeError(w, 401, "登录凭据无效或已过期") writeError(w, 401, message(w, "credentials_are_invalid_or_expired_e1f63b"))
return return
} }
if p.SessionHash == "" { if p.SessionHash == "" {
if !allowAPI { if !allowAPI {
writeError(w, 403, "此接口需要登录会话") writeError(w, 403, message(w, "this_endpoint_requires_a_login_d34b96"))
return return
} }
if p.Scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") { if p.Scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
writeError(w, 403, "只读令牌不能修改数据") writeError(w, 403, message(w, "read_only_tokens_cannot_modify_6cf8e2"))
return return
} }
} }
+4 -4
View File
@@ -5,8 +5,8 @@ import (
"crypto/rand" "crypto/rand"
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"errors"
"net/http" "net/http"
"rawmark/internal/i18n"
"sync" "sync"
"time" "time"
@@ -15,8 +15,8 @@ import (
) )
var ( var (
errCaptchaInvalid = errors.New("验证码错误或已使用,请刷新后重试") errCaptchaInvalid = i18n.New("the_verification_code_is_incorrect_6e789b")
errCaptchaExpired = errors.New("验证码已过期,请刷新后重试") errCaptchaExpired = i18n.New("the_verification_code_has_expired_0b3384")
) )
type loginChallenge struct { type loginChallenge struct {
@@ -113,7 +113,7 @@ func (s *Server) createCaptcha(w http.ResponseWriter, r *http.Request) {
} }
id, item, err := s.captchas.create(clientIP(r), body.ReplaceID) id, item, err := s.captchas.create(clientIP(r), body.ReplaceID)
if err != nil { if err != nil {
writeError(w, 429, err.Error()) writeError(w, 429, i18n.RenderError(err, responseLocale(w)))
return return
} }
var png bytes.Buffer var png bytes.Buffer
+13 -13
View File
@@ -5,12 +5,12 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"regexp" "regexp"
"strings" "strings"
@@ -46,7 +46,7 @@ func (s *Server) openFont(id string) (*os.Root, fontInfo, error) {
err = json.Unmarshal(data, &info) err = json.Unmarshal(data, &info)
} }
if err == nil && (info.ID != id || info.Name == "" || !validFontFormat(info.Format)) { if err == nil && (info.ID != id || info.Name == "" || !validFontFormat(info.Format)) {
err = fmt.Errorf("字体安装信息无效") err = i18n.New("invalid_font_installation_information_77cff8")
} }
if err != nil { if err != nil {
dir.Close() dir.Close()
@@ -88,13 +88,13 @@ func (s *Server) listFonts(w http.ResponseWriter, r *http.Request) {
func (s *Server) importFont(w http.ResponseWriter, r *http.Request) { func (s *Server) importFont(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFontBytes+(1<<20)) r.Body = http.MaxBytesReader(w, r.Body, maxFontBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil { if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, 400, "无法读取字体文件,单个文件上限 30 MB") writeError(w, 400, message(w, "could_not_read_font_file_b7b543"))
return return
} }
defer r.MultipartForm.RemoveAll() defer r.MultipartForm.RemoveAll()
f, header, err := r.FormFile("file") f, header, err := r.FormFile("file")
if err != nil { if err != nil {
writeError(w, 400, "请选择字体文件") writeError(w, 400, message(w, "choose_a_font_file_4c95b9"))
return return
} }
defer f.Close() defer f.Close()
@@ -105,23 +105,23 @@ func (s *Server) importFont(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(data) > maxFontBytes { if len(data) > maxFontBytes {
writeError(w, 413, "字体文件超过 30 MB") writeError(w, 413, message(w, "font_file_exceeds_mb_cc001c"))
return return
} }
magic := map[string]string{"ttf": "\x00\x01\x00\x00", "otf": "OTTO", "woff": "wOFF", "woff2": "wOF2"} magic := map[string]string{"ttf": "\x00\x01\x00\x00", "otf": "OTTO", "woff": "wOFF", "woff2": "wOF2"}
if !validFontFormat(format) || len(data) < 12 || string(data[:4]) != magic[format] { if !validFontFormat(format) || len(data) < 12 || string(data[:4]) != magic[format] {
writeError(w, 400, "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件") writeError(w, 400, message(w, "import_a_valid_ttf_otf_b67fd3"))
return return
} }
if s.FontsDir == "" { if s.FontsDir == "" {
writeError(w, 503, "字体目录未配置") writeError(w, 503, message(w, "font_directory_is_not_configured_73d32a"))
return return
} }
hash := sha256.Sum256(data) hash := sha256.Sum256(data)
id := "custom-" + hex.EncodeToString(hash[:]) id := "custom-" + hex.EncodeToString(hash[:])
info := fontInfo{id, strings.TrimSuffix(filepath.Base(header.Filename), filepath.Ext(header.Filename)), format} info := fontInfo{id, strings.TrimSuffix(filepath.Base(header.Filename), filepath.Ext(header.Filename)), format}
if strings.TrimSpace(info.Name) == "" || len(info.Name) > 200 || strings.ContainsAny(info.Name, "\x00\r\n") { if strings.TrimSpace(info.Name) == "" || len(info.Name) > 200 || strings.ContainsAny(info.Name, "\x00\r\n") {
writeError(w, 400, "字体文件名无效或过长") writeError(w, 400, message(w, "font_filename_is_invalid_or_a3fd5e"))
return return
} }
s.settingsMu.Lock() s.settingsMu.Lock()
@@ -167,7 +167,7 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
dir, info, err := s.openFont(r.PathValue("id")) dir, info, err := s.openFont(r.PathValue("id"))
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
s.settingsMu.Unlock() s.settingsMu.Unlock()
writeError(w, 404, "字体不存在") writeError(w, 404, message(w, "font_not_found_4383e9"))
return return
} }
if err != nil { if err != nil {
@@ -186,7 +186,7 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
stat, err := f.Stat() stat, err := f.Stat()
if err != nil || !stat.Mode().IsRegular() { if err != nil || !stat.Mode().IsRegular() {
if err == nil { if err == nil {
err = fmt.Errorf("字体必须为普通文件") err = i18n.New("a_font_must_be_a_5b8b51")
} }
internalError(w, r, err) internalError(w, r, err)
return return
@@ -198,14 +198,14 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
if id == "maple" { if id == "maple" {
writeError(w, 400, "Maple 是默认字体,不可删除") writeError(w, 400, message(w, "maple_is_the_default_font_a8a571"))
return return
} }
s.settingsMu.Lock() s.settingsMu.Lock()
defer s.settingsMu.Unlock() defer s.settingsMu.Unlock()
dir, _, err := s.openFont(id) dir, _, err := s.openFont(id)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "字体不存在") writeError(w, 404, message(w, "font_not_found_4383e9"))
return return
} }
if err != nil { if err != nil {
@@ -247,7 +247,7 @@ func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
} }
s.hub.Broadcast(events.Event{Type: "settings"}) s.hub.Broadcast(events.Event{Type: "settings"})
if err := root.RemoveAll(removed); err != nil { if err := root.RemoveAll(removed); err != nil {
internalError(w, r, fmt.Errorf("字体已停用,但文件清理失败:%w", err)) internalError(w, r, i18n.New("font_disabled_but_file_cleanup_d8b892", err))
return return
} }
writeJSON(w, 200, map[string]bool{"ok": true}) writeJSON(w, 200, map[string]bool{"ok": true})
+13 -13
View File
@@ -4,13 +4,13 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url" "net/url"
"rawmark/internal/i18n"
"syscall" "syscall"
"time" "time"
@@ -18,8 +18,8 @@ import (
) )
var ( var (
errImageAddress = errors.New("图片地址必须是公网 HTTP 或 HTTPS 地址") errImageAddress = i18n.New("image_url_must_be_a_391763")
errImageType = errors.New("仅支持导入 PNG、JPEG、GIF 和 WebP 图片") errImageType = i18n.New("only_png_jpeg_gif_and_a413cf")
) )
var blockedImageNetworks = []netip.Prefix{ var blockedImageNetworks = []netip.Prefix{
@@ -98,7 +98,7 @@ var remoteImageClient = &http.Client{
}, },
CheckRedirect: func(r *http.Request, via []*http.Request) error { CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 5 { if len(via) >= 5 {
return errors.New("图片来源重定向次数过多") return i18n.New("too_many_redirects_from_the_6738ba")
} }
r.Header.Del("Referer") r.Header.Del("Referer")
return checkImageURL(r.URL) return checkImageURL(r.URL)
@@ -137,12 +137,12 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
} }
u, err := url.Parse(body.URL) u, err := url.Parse(body.URL)
if err != nil || checkImageURL(u) != nil { if err != nil || checkImageURL(u) != nil {
writeError(w, 400, errImageAddress.Error()) writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return return
} }
note := r.URL.Query().Get("note") note := r.URL.Query().Get("note")
if !vault.IsNote(note) { if !vault.IsNote(note) {
writeError(w, 400, "所属笔记路径无效") writeError(w, 400, message(w, "invalid_note_path_be69bd"))
return return
} }
if _, err := s.vault.Stat(note); err != nil { if _, err := s.vault.Stat(note); err != nil {
@@ -161,7 +161,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
} }
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil)
if err != nil { if err != nil {
writeError(w, 400, errImageAddress.Error()) writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return return
} }
res, err := remoteImageClient.Do(req) res, err := remoteImageClient.Do(req)
@@ -171,7 +171,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
continue continue
} }
if errors.Is(err, errImageAddress) { if errors.Is(err, errImageAddress) {
writeError(w, 400, errImageAddress.Error()) writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return return
} }
// url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。 // url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。
@@ -180,7 +180,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
err = urlError.Err err = urlError.Err
} }
slog.Error("下载图片失败", "err", err) slog.Error("下载图片失败", "err", err)
writeError(w, 502, "下载图片失败") writeError(w, 502, message(w, "could_not_download_image_b4a293"))
return return
} }
if res.StatusCode < 200 || res.StatusCode >= 300 { if res.StatusCode < 200 || res.StatusCode >= 300 {
@@ -188,7 +188,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
if attempt == 0 && (res.StatusCode == 429 || res.StatusCode >= 500 && res.StatusCode < 600) { if attempt == 0 && (res.StatusCode == 429 || res.StatusCode >= 500 && res.StatusCode < 600) {
continue continue
} }
writeError(w, 502, fmt.Sprintf("图片来源返回 HTTP %d", res.StatusCode)) writeError(w, 502, message(w, "image_source_http", res.StatusCode))
return return
} }
data, ext, err := readImageDownload(res.Body) data, ext, err := readImageDownload(res.Body)
@@ -201,10 +201,10 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, vault.ErrTooLarge) { if errors.Is(err, vault.ErrTooLarge) {
vaultError(w, r, err) vaultError(w, r, err)
} else if errors.Is(err, errImageType) { } else if errors.Is(err, errImageType) {
writeError(w, 422, err.Error()) writeError(w, 422, i18n.RenderError(err, responseLocale(w)))
} else { } else {
slog.Error("读取下载图片失败", "err", err) slog.Error("读取下载图片失败", "err", err)
writeError(w, 502, "读取下载图片失败") writeError(w, 502, message(w, "could_not_read_downloaded_image_4b7fdf"))
} }
return return
} }
@@ -215,7 +215,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
return return
} }
if info.IsDir() { if info.IsDir() {
writeError(w, 400, "所属路径必须是笔记文件") writeError(w, 400, message(w, "the_owning_path_must_be_867b53"))
return return
} }
link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes) link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes)
+7 -3
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"errors" "errors"
"net/http" "net/http"
"rawmark/internal/i18n"
"rawmark/internal/vault" "rawmark/internal/vault"
) )
@@ -13,16 +14,16 @@ func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(8 << 20); err != nil { if err := r.ParseMultipartForm(8 << 20); err != nil {
var limit *http.MaxBytesError var limit *http.MaxBytesError
if errors.As(err, &limit) { if errors.As(err, &limit) {
writeError(w, 413, "导入文件总量不能超过 100 MB") writeError(w, 413, message(w, "total_import_size_must_not_23ac37"))
} else { } else {
writeError(w, 400, "无法读取导入表单") writeError(w, 400, message(w, "could_not_read_import_form_69f1c3"))
} }
return return
} }
defer r.MultipartForm.RemoveAll() defer r.MultipartForm.RemoveAll()
headers := r.MultipartForm.File["files"] headers := r.MultipartForm.File["files"]
if len(headers) == 0 || len(headers) > 10_000 { if len(headers) == 0 || len(headers) > 10_000 {
writeError(w, 400, "请选择 Markdown 文件或一个 ZIP") writeError(w, 400, message(w, "choose_markdown_files_or_a_bea10e"))
return return
} }
files := make([]vault.ImportFile, 0, len(headers)) files := make([]vault.ImportFile, 0, len(headers))
@@ -40,5 +41,8 @@ func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err) vaultError(w, r, err)
return return
} }
for i, warning := range result.WarningMessages {
result.Warnings[i] = i18n.RenderError(warning, responseLocale(w))
}
writeJSON(w, http.StatusCreated, result) writeJSON(w, http.StatusCreated, result)
} }
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"rawmark/internal/i18n"
"strings"
)
func localized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Language", string(i18n.Negotiate(r.Header.Get("Accept-Language"))))
}
next.ServeHTTP(w, r)
})
}
func responseLocale(w http.ResponseWriter) i18n.Locale {
return i18n.Negotiate(w.Header().Get("Content-Language"))
}
func message(w http.ResponseWriter, key string, args ...any) string {
return i18n.Text(responseLocale(w), key, args...)
}
func validateLocale(settings map[string]json.RawMessage) error {
if raw, ok := settings["locale"]; ok {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil
}
var locale string
if err := json.Unmarshal(raw, &locale); err != nil || (locale != "en" && locale != "zh-CN") {
return i18n.New("invalid_locale")
}
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"rawmark/internal/search"
"rawmark/internal/vault"
"reflect"
"strings"
"testing"
"time"
"rawmark/internal/i18n"
)
func localizedResponse(t *testing.T, res *http.Response) response {
t.Helper()
defer res.Body.Close()
var body response
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return body
}
func TestLocalizedHTTPContracts(t *testing.T) {
f := newFixture(t)
for _, language := range []string{"en", "zh-CN"} {
h := map[string]string{"Accept-Language": language}
res := f.do(newClient(), "GET", "/api/v1/settings", "", h)
if res.StatusCode != 401 || res.Header.Get("Content-Language") != language || res.Header.Get("Cache-Control") != "no-store" {
t.Fatal("authentication response contract changed")
}
body := localizedResponse(t, res)
want := "Please sign in"
if language == "zh-CN" {
want = "请先登录"
}
if body.Message != want || body.Code != 40100 {
t.Fatalf("%s: %+v", language, body)
}
res = f.do(f.admin, "PATCH", "/api/v1/settings", `{"locale":"fr"}`, h)
if res.StatusCode != 400 {
t.Fatal("invalid locale accepted")
}
if body := localizedResponse(t, res); body.Code != 40000 || body.Message != i18n.Text(i18n.Locale(language), "invalid_locale") {
t.Fatal(body)
}
id, _ := challengeFor(t, f)
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, "invalid", "password123"), h)
if res.StatusCode != 400 {
t.Fatal("invalid captcha status changed")
}
if body := localizedResponse(t, res); body.Code != CodeCaptchaInvalid || !strings.Contains(body.Message, map[string]string{"en": "Refresh", "zh-CN": "刷新"}[language]) {
t.Fatal(body)
}
id, code := challengeFor(t, f)
f.server.captchas.mu.Lock()
challenge := f.server.captchas.challenges[id]
challenge.expires = time.Now().Add(-time.Second)
f.server.captchas.challenges[id] = challenge
f.server.captchas.mu.Unlock()
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), h)
if body := localizedResponse(t, res); body.Code != CodeCaptchaExpired {
t.Fatal(body)
}
}
for _, value := range []string{`{"locale":"zh-CN"}`, `{"locale":"en"}`, `{"locale":null}`} {
res := f.do(f.admin, "PATCH", "/api/v1/settings", value, nil)
if res.StatusCode != 200 {
t.Fatal("valid language rejected")
}
res.Body.Close()
}
if err := validatePreferences(map[string]json.RawMessage{settingsKey: json.RawMessage(`{"locale":"fr"}`)}); !errors.Is(err, errMaintenanceInput) {
t.Fatal("invalid imported language accepted")
}
}
func TestMaintenanceStatusIsRenderedForEachReader(t *testing.T) {
f := newFixture(t)
if err := f.server.maintain("export_backup_e9d3dd", func() error { return i18n.New("invalid_locale") }); err == nil {
t.Fatal("missing failure")
}
for _, language := range []string{"en", "zh-CN"} {
data := decode[maintenanceStatus](t, f.do(f.admin, "GET", "/api/v1/maintenance/status", "", map[string]string{"Accept-Language": language}))
if data.Operation != i18n.Text(i18n.Locale(language), "export_backup_e9d3dd") || data.Error != i18n.Text(i18n.Locale(language), "invalid_locale") {
t.Fatal(data)
}
}
}
func TestLocalizedResultDetailsDoNotRewriteSourceData(t *testing.T) {
f := newFixture(t)
const content = "---\ntitle: User title\ntags: 1\n---\nUser content\n"
f.write("bad.md", content)
f.write("used.md", "![](./assets/used.png)")
f.write("assets/used.png", "synthetic image")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
h := map[string]string{"Accept-Language": language}
result := decode[struct {
Notes []struct {
Path string
Warnings []string
}
}](t, f.do(f.admin, "GET", "/api/v1/metadata", "", h))
found := false
for _, note := range result.Notes {
if note.Path == "bad.md" {
found = true
if len(note.Warnings) != 1 || note.Warnings[0] != i18n.Text(i18n.Locale(language), "metadata_tags") {
t.Fatal(note.Warnings)
}
}
}
if !found {
t.Fatal("note missing")
}
// Metadata warnings still block destructive cleanup, in the requested language.
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, h)
body := localizedResponse(t, res)
if res.StatusCode != 400 || !strings.Contains(body.Message, i18n.Text(i18n.Locale(language), "metadata_tags")) {
t.Fatal(body)
}
}
got, _, err := f.server.vault.Read("bad.md")
if err != nil || string(got) != content {
t.Fatal("localization changed note contents")
}
if _, err := f.server.vault.Delete("bad.md"); err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, map[string]string{"Accept-Language": language}))
if len(result.Skipped) != 1 || result.Skipped[0].Reason != i18n.Text(i18n.Locale(language), "image_in_use") {
t.Fatal(result)
}
}
}
func TestLocalizedMetadataSkipsDeletedOrMovedWarningNotes(t *testing.T) {
for _, operation := range []string{"delete", "move"} {
t.Run(operation, func(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
f.write("good.md", "Good\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
var err error
if operation == "delete" {
_, err = f.server.vault.Delete("bad.md")
} else {
_, err = f.server.vault.Move("bad.md", "moved.md")
}
if err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
res := f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language})
if res.StatusCode != http.StatusOK {
t.Fatalf("stale warning note invalidated metadata: HTTP %d", res.StatusCode)
}
result := decode[struct{ Notes []search.NoteMetadata }](t, res)
if len(result.Notes) != 1 || result.Notes[0].Path != "good.md" {
t.Fatalf("unexpected surviving metadata: %+v", result.Notes)
}
}
})
}
}
func TestLocalizedMetadataKeepsReanalyzedNoteConsistent(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
const latest = "---\ntitle: Updated title\ntags: [new]\n---\n![](./assets/live.png)\n[Note](./other.md)\n"
f.write("bad.md", latest)
for _, language := range []string{"en", "zh-CN"} {
result := decode[struct{ Notes []search.NoteMetadata }](t, f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language}))
if len(result.Notes) != 1 {
t.Fatal(result)
}
note := result.Notes[0]
if note.Rev != vault.Rev([]byte(latest)) || !reflect.DeepEqual(note.Metadata, vault.Analyze("bad.md", []byte(latest))) {
t.Fatalf("metadata mixes file revisions: %+v", note)
}
}
}
func TestLocalizedMetadataPreservesReadErrors(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
file := filepath.Join(f.root, "bad.md")
if err := os.Remove(file); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(file, 0o755); err != nil {
t.Fatal(err)
}
if res := f.do(f.admin, "GET", "/api/v1/metadata", "", nil); res.StatusCode != http.StatusInternalServerError {
t.Fatalf("read failure was hidden: HTTP %d", res.StatusCode)
}
}
+43 -31
View File
@@ -5,12 +5,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"mime" "mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"runtime" "runtime"
"strings" "strings"
"syscall" "syscall"
@@ -26,6 +26,7 @@ type maintenanceStatus struct {
Error string `json:"error"` Error string `json:"error"`
FinishedAt int64 `json:"finishedAt"` FinishedAt int64 `json:"finishedAt"`
Backup string `json:"backup,omitempty"` Backup string `json:"backup,omitempty"`
cause error
} }
// gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。 // gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。
@@ -36,7 +37,7 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
return return
} }
if s.maintenanceBusy.Load() && r.URL.Path != "/api/v1/maintenance/status" { if s.maintenanceBusy.Load() && r.URL.Path != "/api/v1/maintenance/status" {
writeError(w, 503, "正在维护,请稍后重试") writeError(w, 503, message(w, "maintenance_is_in_progress_please_64ef9d"))
return return
} }
resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/") resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/")
@@ -47,7 +48,7 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
return return
} }
if !s.activity.TryRLock() { if !s.activity.TryRLock() {
writeError(w, 503, "正在维护,请稍后重试") writeError(w, 503, message(w, "maintenance_is_in_progress_please_64ef9d"))
return return
} }
defer s.activity.RUnlock() defer s.activity.RUnlock()
@@ -55,8 +56,8 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
}) })
} }
var errMaintenanceBusy = errors.New("其他维护操作正在执行") var errMaintenanceBusy = i18n.New("another_maintenance_operation_is_in_da1aa4")
var errMaintenanceInput = errors.New("维护请求无效") var errMaintenanceInput = i18n.New("invalid_maintenance_request_ef9c16")
func (s *Server) maintain(operation string, fn func() error) error { func (s *Server) maintain(operation string, fn func() error) error {
if !s.maintenanceMu.TryLock() { if !s.maintenanceMu.TryLock() {
@@ -75,6 +76,7 @@ func (s *Server) maintain(operation string, fn func() error) error {
s.maintenanceState.FinishedAt = time.Now().UnixMilli() s.maintenanceState.FinishedAt = time.Now().UnixMilli()
if err != nil { if err != nil {
s.maintenanceState.Error = err.Error() s.maintenanceState.Error = err.Error()
s.maintenanceState.cause = err
} }
s.maintenanceStatusMu.Unlock() s.maintenanceStatusMu.Unlock()
if !s.restarting.Load() { if !s.restarting.Load() {
@@ -85,19 +87,26 @@ func (s *Server) maintain(operation string, fn func() error) error {
func (s *Server) maintenanceInfo(w http.ResponseWriter, r *http.Request) { func (s *Server) maintenanceInfo(w http.ResponseWriter, r *http.Request) {
s.maintenanceStatusMu.Lock() s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock() defer s.maintenanceStatusMu.Unlock()
writeJSON(w, 200, s.maintenanceState) state := s.maintenanceState
if state.Operation != "" {
state.Operation = message(w, state.Operation)
}
if state.cause != nil {
state.Error = i18n.RenderError(state.cause, responseLocale(w))
}
writeJSON(w, 200, state)
} }
func (s *Server) maintenanceError(w http.ResponseWriter, r *http.Request, err error) { func (s *Server) maintenanceError(w http.ResponseWriter, r *http.Request, err error) {
var pathError *os.PathError var pathError *os.PathError
switch { switch {
case errors.Is(err, errMaintenanceBusy): case errors.Is(err, errMaintenanceBusy):
writeError(w, 503, err.Error()) writeError(w, 503, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, syscall.ENOSPC): case errors.Is(err, syscall.ENOSPC):
writeError(w, 503, "磁盘空间不足") writeError(w, 503, message(w, "not_enough_disk_space_a53a70"))
case errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR): case errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR):
internalError(w, r, err) internalError(w, r, err)
case errors.Is(err, errMaintenanceInput), errors.Is(err, backup.ErrInvalidArchive): case errors.Is(err, errMaintenanceInput), errors.Is(err, backup.ErrInvalidArchive):
writeError(w, 400, "操作失败:"+err.Error()) writeError(w, 400, message(w, "operation_failed", i18n.RenderError(err, responseLocale(w))))
default: default:
internalError(w, r, err) internalError(w, r, err)
} }
@@ -200,7 +209,7 @@ func sendDownload(w http.ResponseWriter, r *http.Request, file, name string) err
func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) { func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
kind := r.PathValue("kind") kind := r.PathValue("kind")
if kind != "full" && kind != "settings" { if kind != "full" && kind != "settings" {
writeError(w, 400, "备份类型无效") writeError(w, 400, message(w, "invalid_backup_type_f5ff70"))
return return
} }
temp, err := s.tempDir() temp, err := s.tempDir()
@@ -217,7 +226,7 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
file.Close() file.Close()
os.Remove(name) os.Remove(name)
defer os.Remove(name) defer os.Remove(name)
err = s.maintain("导出备份", func() error { err = s.maintain("export_backup_e9d3dd", func() error {
if err := s.createArchive(r.Context(), kind, name); err != nil { if err := s.createArchive(r.Context(), kind, name); err != nil {
return err return err
} }
@@ -228,7 +237,7 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
} }
} }
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) { func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
err := s.maintain("导出笔记数据", func() error { err := s.maintain("export_note_data_45a4e1", func() error {
dataRoot, err := filepath.EvalSymlinks(s.DataDir) dataRoot, err := filepath.EvalSymlinks(s.DataDir)
if err != nil { if err != nil {
return err return err
@@ -253,39 +262,42 @@ func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
func validatePreferences(values map[string]json.RawMessage) error { func validatePreferences(values map[string]json.RawMessage) error {
for key, raw := range values { for key, raw := range values {
if len(raw) > maxJSONBytes { if len(raw) > maxJSONBytes {
return fmt.Errorf("%w:单项设置超过 1 MiB", errMaintenanceInput) return i18n.New("w_a_single_setting_exceeds_c7dccb", errMaintenanceInput)
} }
switch { switch {
case key == settingsKey: case key == settingsKey:
var settings map[string]json.RawMessage var settings map[string]json.RawMessage
if err := json.Unmarshal(raw, &settings); err != nil || settings == nil { if err := json.Unmarshal(raw, &settings); err != nil || settings == nil {
return fmt.Errorf("%w:偏好设置必须为对象", errMaintenanceInput) return i18n.New("w_preferences_must_be_an_8d2724", errMaintenanceInput)
}
if err := validateLocale(settings); err != nil {
return errors.Join(errMaintenanceInput, err)
} }
if _, err := trashRetentionDays(settings); err != nil { if _, err := trashRetentionDays(settings); err != nil {
return fmt.Errorf("%w:%w", errMaintenanceInput, err) return i18n.New("w_w_f0fd7d", errMaintenanceInput, err)
} }
if value, ok := settings["shortcuts"]; ok { if value, ok := settings["shortcuts"]; ok {
var shortcuts map[string][]string var shortcuts map[string][]string
if err := json.Unmarshal(value, &shortcuts); err != nil { if err := json.Unmarshal(value, &shortcuts); err != nil {
return fmt.Errorf("%w:快捷键格式无效", errMaintenanceInput) return i18n.New("w_invalid_shortcut_format_0acab4", errMaintenanceInput)
} }
} }
case key == pluginEnabledKey: case key == pluginEnabledKey:
var ids map[string]bool var ids map[string]bool
if err := json.Unmarshal(raw, &ids); err != nil { if err := json.Unmarshal(raw, &ids); err != nil {
return fmt.Errorf("%w:%w", errMaintenanceInput, err) return i18n.New("w_w_f0fd7d", errMaintenanceInput, err)
} }
for id := range ids { for id := range ids {
if !validPluginID(id) { if !validPluginID(id) {
return fmt.Errorf("%w:插件 ID 无效", errMaintenanceInput) return i18n.New("w_invalid_plugin_id_7f3512", errMaintenanceInput)
} }
} }
case strings.HasPrefix(key, "plugin.data."): case strings.HasPrefix(key, "plugin.data."):
if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) { if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) {
return fmt.Errorf("%w:插件数据无效", errMaintenanceInput) return i18n.New("w_invalid_plugin_data_1f5fe3", errMaintenanceInput)
} }
default: default:
return fmt.Errorf("%w:设置包包含不允许的设置项", errMaintenanceInput) return i18n.New("w_the_settings_package_contains_e70b59", errMaintenanceInput)
} }
} }
return nil return nil
@@ -296,7 +308,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
return nil, err return nil, err
} }
if info.Size() > 32<<20 { if info.Size() > 32<<20 {
return nil, fmt.Errorf("%w:设置包设置内容超过 32 MiB", errMaintenanceInput) return nil, i18n.New("w_settings_package_content_exceeds_baf8eb", errMaintenanceInput)
} }
raw, err := os.ReadFile(filepath.Join(stage, "settings.json")) raw, err := os.ReadFile(filepath.Join(stage, "settings.json"))
if err != nil { if err != nil {
@@ -304,7 +316,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
} }
var values map[string]json.RawMessage var values map[string]json.RawMessage
if err = json.Unmarshal(raw, &values); err != nil || values == nil { if err = json.Unmarshal(raw, &values); err != nil || values == nil {
return nil, fmt.Errorf("%w:设置包格式无效", errMaintenanceInput) return nil, i18n.New("w_invalid_settings_package_format_2e7bee", errMaintenanceInput)
} }
if err = validatePreferences(values); err != nil { if err = validatePreferences(values); err != nil {
return nil, err return nil, err
@@ -322,16 +334,16 @@ func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
return return
} }
if !body.Confirmed { if !body.Confirmed {
writeError(w, 400, "请先确认导入设置") writeError(w, 400, message(w, "confirm_settings_import_first_e89aa3"))
return return
} }
err := s.maintain("导入设置", func() error { err := s.maintain("import_settings_1a80d4", func() error {
meta, dir, err := s.readUpload(r, body.ID) meta, dir, err := s.readUpload(r, body.ID)
if err != nil { if err != nil {
return err return err
} }
if !meta.Validated || meta.Kind != "settings" { if !meta.Validated || meta.Kind != "settings" {
return fmt.Errorf("%w:请先校验设置包", errMaintenanceInput) return i18n.New("w_validate_the_settings_package_0f4f56", errMaintenanceInput)
} }
stage := filepath.Join(dir, "stage") stage := filepath.Join(dir, "stage")
values, err := readPreferences(stage) values, err := readPreferences(stage)
@@ -436,10 +448,10 @@ func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
if !body.Confirmed { if !body.Confirmed {
writeError(w, 400, "请先确认重置") writeError(w, 400, message(w, "confirm_reset_first_22ae3d"))
return return
} }
err := s.maintain("重置设置", func() error { err := s.maintain("reset_settings_9a70ef", func() error {
if _, err := s.saveRollback(r.Context(), "settings"); err != nil { if _, err := s.saveRollback(r.Context(), "settings"); err != nil {
return err return err
} }
@@ -458,10 +470,10 @@ func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) {
operation := r.PathValue("operation") operation := r.PathValue("operation")
if operation != "optimize" && operation != "rebuild" { if operation != "optimize" && operation != "rebuild" {
writeError(w, 400, "索引操作无效") writeError(w, 400, message(w, "invalid_index_operation_be898b"))
return return
} }
err := s.maintain("索引维护", func() error { err := s.maintain("index_maintenance_676706", func() error {
if operation == "rebuild" { if operation == "rebuild" {
return s.index.Rebuild(r.Context()) return s.index.Rebuild(r.Context())
} }
@@ -476,10 +488,10 @@ func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) exportLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) exportLogs(w http.ResponseWriter, r *http.Request) {
if s.Logs == nil { if s.Logs == nil {
writeError(w, 503, "当前服务尚未启用日志文件") writeError(w, 503, message(w, "log_files_are_not_enabled_dcf4df"))
return return
} }
err := s.maintain("导出系统日志", func() error { err := s.maintain("export_system_logs_d05855", func() error {
temp, err := s.tempDir() temp, err := s.tempDir()
if err != nil { if err != nil {
return err return err
+2 -2
View File
@@ -307,7 +307,7 @@ func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&body); err != nil { if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if res.StatusCode != 400 || body.Code != 40000 || !strings.Contains(body.Message, "备份内容无效") { if res.StatusCode != 400 || body.Code != 40000 || !strings.Contains(body.Message, "Invalid backup contents") {
t.Fatalf("无效备份响应错误: %d %+v", res.StatusCode, body) t.Fatalf("无效备份响应错误: %d %+v", res.StatusCode, body)
} }
}) })
@@ -348,7 +348,7 @@ func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&body); err != nil { if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if res.StatusCode != 500 || body.Code != 50000 || body.Message != "服务器内部错误" { if res.StatusCode != 500 || body.Code != 50000 || body.Message != "Internal server error" {
t.Fatalf("磁盘故障被当作输入错误: %d %+v", res.StatusCode, body) t.Fatalf("磁盘故障被当作输入错误: %d %+v", res.StatusCode, body)
} }
}) })
+29 -29
View File
@@ -5,11 +5,11 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -39,12 +39,12 @@ func uploadOwner(r *http.Request) string {
func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) { func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) {
var meta maintenanceUpload var meta maintenanceUpload
if !uploadIDPattern.MatchString(id) { if !uploadIDPattern.MatchString(id) {
return meta, "", fmt.Errorf("%w:上传 ID 无效", errMaintenanceInput) return meta, "", i18n.New("w_invalid_upload_id_23d358", errMaintenanceInput)
} }
dir := filepath.Join(s.DataDir, ".maintenance", "uploads", id) dir := filepath.Join(s.DataDir, ".maintenance", "uploads", id)
data, err := os.ReadFile(filepath.Join(dir, "upload.json")) data, err := os.ReadFile(filepath.Join(dir, "upload.json"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return meta, "", fmt.Errorf("%w:上传不存在或已过期", errMaintenanceInput) return meta, "", i18n.New("w_upload_does_not_exist_918d8d", errMaintenanceInput)
} }
if err != nil { if err != nil {
return meta, "", err return meta, "", err
@@ -53,7 +53,7 @@ func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, stri
return meta, "", err return meta, "", err
} }
if meta.Owner != uploadOwner(r) { if meta.Owner != uploadOwner(r) {
return meta, "", fmt.Errorf("%w:上传不属于当前会话", errMaintenanceInput) return meta, "", i18n.New("w_upload_does_not_belong_c4d06d", errMaintenanceInput)
} }
return meta, dir, nil return meta, dir, nil
} }
@@ -70,7 +70,7 @@ func enoughSpace(dir string, size int64) error {
return err return err
} }
if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) { if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) {
return fmt.Errorf("磁盘空间不足:%w", syscall.ENOSPC) return i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC)
} }
return nil return nil
} }
@@ -83,11 +83,11 @@ func (s *Server) startMaintenanceUpload(w http.ResponseWriter, r *http.Request)
return return
} }
if (body.Kind != "full" && body.Kind != "settings") || body.Size <= 0 || body.Size > backup.MaxArchiveBytes { if (body.Kind != "full" && body.Kind != "settings") || body.Size <= 0 || body.Size > backup.MaxArchiveBytes {
writeError(w, 400, "请选择有效备份,压缩大小上限 2 GiB") writeError(w, 400, message(w, "choose_a_valid_backup_with_51ba41"))
return return
} }
var id string var id string
err := s.maintain("准备上传", func() error { err := s.maintain("prepare_upload_ed0bd9", func() error {
root := filepath.Join(s.DataDir, ".maintenance", "uploads") root := filepath.Join(s.DataDir, ".maintenance", "uploads")
if err := os.MkdirAll(root, 0700); err != nil { if err := os.MkdirAll(root, 0700); err != nil {
return err return err
@@ -121,17 +121,17 @@ func randBytes() []byte { bytes := make([]byte, 16); rand.Read(bytes); return by
func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64) offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
if err != nil || offset < 0 { if err != nil || offset < 0 {
writeError(w, 400, "上传偏移无效") writeError(w, 400, message(w, "invalid_upload_offset_3bdbf1"))
return return
} }
var next int64 var next int64
err = s.maintain("上传备份", func() error { err = s.maintain("upload_backup_83b1a9", func() error {
meta, dir, err := s.readUpload(r, r.PathValue("id")) meta, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil { if err != nil {
return err return err
} }
if meta.Validated { if meta.Validated {
return fmt.Errorf("%w:已校验的上传不可修改", errMaintenanceInput) return i18n.New("w_a_validated_upload_cannot_ce0e63", errMaintenanceInput)
} }
f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_WRONLY, 0600) f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_WRONLY, 0600)
if err != nil { if err != nil {
@@ -143,22 +143,22 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
return err return err
} }
if info.Size() != offset { if info.Size() != offset {
return fmt.Errorf("%w:上传偏移不匹配,请重新上传", errMaintenanceInput) return i18n.New("w_upload_offset_does_not_95e21e", errMaintenanceInput)
} }
limit := min(maintenanceChunkBytes, meta.Size-offset) limit := min(maintenanceChunkBytes, meta.Size-offset)
if limit <= 0 { if limit <= 0 {
return fmt.Errorf("%w:上传大小超出清单", errMaintenanceInput) return i18n.New("w_upload_size_exceeds_the_0bb329", errMaintenanceInput)
} }
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit)) data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil { if err != nil {
var tooLarge *http.MaxBytesError var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { if errors.As(err, &tooLarge) {
return fmt.Errorf("%w:%w", errMaintenanceInput, err) return i18n.New("w_w_f0fd7d", errMaintenanceInput, err)
} }
return err return err
} }
if len(data) == 0 { if len(data) == 0 {
return fmt.Errorf("%w:上传内容为空", errMaintenanceInput) return i18n.New("w_upload_content_is_empty_7c0bb2", errMaintenanceInput)
} }
if err = enoughSpace(dir, int64(len(data))); err != nil { if err = enoughSpace(dir, int64(len(data))); err != nil {
return err return err
@@ -185,7 +185,7 @@ func backupResourceError(err error) error {
if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) { if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) {
return err return err
} }
return fmt.Errorf("%w:%w", backup.ErrInvalidArchive, err) return i18n.New("w_w_f0fd7d", backup.ErrInvalidArchive, err)
} }
func (s *Server) validateResources(stage string) error { func (s *Server) validateResources(stage string) error {
@@ -200,7 +200,7 @@ func (s *Server) validateResources(stage string) error {
} }
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
return fmt.Errorf("%w:资源目录内包含非目录条目", errMaintenanceInput) return i18n.New("w_the_resource_directory_contains_3898a5", errMaintenanceInput)
} }
switch kind { switch kind {
case "fonts": case "fonts":
@@ -227,7 +227,7 @@ func (s *Server) validateResources(stage string) error {
func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
var manifest backup.Manifest var manifest backup.Manifest
var bytes int64 var bytes int64
err := s.maintain("校验备份", func() error { err := s.maintain("validate_backup_09df97", func() error {
meta, dir, err := s.readUpload(r, r.PathValue("id")) meta, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil { if err != nil {
return err return err
@@ -237,7 +237,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
return err return err
} }
if info.Size() != meta.Size { if info.Size() != meta.Size {
return fmt.Errorf("%w:上传尚未完成", errMaintenanceInput) return i18n.New("w_upload_is_not_complete_8ef575", errMaintenanceInput)
} }
meta.Validated = false meta.Validated = false
if err = saveUpload(dir, meta); err != nil { if err = saveUpload(dir, meta); err != nil {
@@ -255,12 +255,12 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
return err return err
} }
if manifest.Kind != meta.Kind { if manifest.Kind != meta.Kind {
return fmt.Errorf("%w:备份类型与所选操作不符", errMaintenanceInput) return i18n.New("w_backup_type_does_not_8e5e8c", errMaintenanceInput)
} }
for _, entry := range manifest.Entries { for _, entry := range manifest.Entries {
bytes += entry.Size bytes += entry.Size
if strings.HasPrefix(entry.Path, "vault/") && backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(entry.Path, "vault/")) { if strings.HasPrefix(entry.Path, "vault/") && backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(entry.Path, "vault/")) {
return fmt.Errorf("%w:笔记库内容与应用目录冲突", errMaintenanceInput) return i18n.New("w_vault_content_conflicts_with_6e3d24", errMaintenanceInput)
} }
} }
if err = enoughSpace(dir, bytes*2); err != nil { if err = enoughSpace(dir, bytes*2); err != nil {
@@ -284,7 +284,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
} else { } else {
var databaseError *sqlite.Error var databaseError *sqlite.Error
if !errors.As(err, &databaseError) { if !errors.As(err, &databaseError) {
err = fmt.Errorf("%w:%w", backup.ErrInvalidArchive, err) err = i18n.New("w_w_f0fd7d", backup.ErrInvalidArchive, err)
} }
} }
err = errors.Join(err, st.Close()) err = errors.Join(err, st.Close())
@@ -304,7 +304,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
writeJSON(w, 200, map[string]any{"kind": manifest.Kind, "createdAt": manifest.CreatedAt, "entries": len(manifest.Entries), "size": bytes}) writeJSON(w, 200, map[string]any{"kind": manifest.Kind, "createdAt": manifest.CreatedAt, "entries": len(manifest.Entries), "size": bytes})
} }
func (s *Server) deleteMaintenanceUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
err := s.maintain("取消上传", func() error { err := s.maintain("cancel_upload_a24836", func() error {
_, dir, err := s.readUpload(r, r.PathValue("id")) _, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil { if err != nil {
return err return err
@@ -326,20 +326,20 @@ func (s *Server) restoreInstance(w http.ResponseWriter, r *http.Request) {
return return
} }
if !body.Confirmed { if !body.Confirmed {
writeError(w, 400, "请先确认替换实例数据并重新登录") writeError(w, 400, message(w, "confirm_replacing_instance_data_and_52f3ca"))
return return
} }
if s.Restart == nil { if s.Restart == nil {
writeError(w, 503, "当前服务不支持实例恢复") writeError(w, 503, message(w, "this_service_does_not_support_a9a8ba"))
return return
} }
err := s.maintain("恢复完整实例", func() error { err := s.maintain("restore_full_instance_9b4ee0", func() error {
meta, dir, err := s.readUpload(r, body.ID) meta, dir, err := s.readUpload(r, body.ID)
if err != nil { if err != nil {
return err return err
} }
if !meta.Validated || meta.Kind != "full" { if !meta.Validated || meta.Kind != "full" {
return fmt.Errorf("%w:请先校验完整服务备份", errMaintenanceInput) return i18n.New("w_validate_the_full_instance_6d329d", errMaintenanceInput)
} }
file, err := s.saveRollback(r.Context(), "full") file, err := s.saveRollback(r.Context(), "full")
if err != nil { if err != nil {
@@ -367,7 +367,7 @@ type cleanupResult struct {
func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) { func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
result := cleanupResult{Failed: []string{}} result := cleanupResult{Failed: []string{}}
err := s.maintain("清理临时文件", func() error { err := s.maintain("clean_temporary_files_af91ae", func() error {
// temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。 // temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。
temp, err := s.tempDir() temp, err := s.tempDir()
if err != nil { if err != nil {
@@ -435,7 +435,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
e = os.RemoveAll(target) e = os.RemoveAll(target)
} }
if e != nil { if e != nil {
result.Failed = append(result.Failed, e.Error()) result.Failed = append(result.Failed, i18n.RenderError(e, responseLocale(w)))
} else { } else {
result.Files += count result.Files += count
result.Bytes += size result.Bytes += size
@@ -454,5 +454,5 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
func (s *Server) SetRestoreFailure(err error) { func (s *Server) SetRestoreFailure(err error) {
s.maintenanceStatusMu.Lock() s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock() defer s.maintenanceStatusMu.Unlock()
s.maintenanceState = maintenanceStatus{Operation: "恢复完整实例", Error: err.Error(), FinishedAt: time.Now().UnixMilli()} s.maintenanceState = maintenanceStatus{Operation: "restore_full_instance_9b4ee0", Error: err.Error(), cause: err, FinishedAt: time.Now().UnixMilli()}
} }
+1 -1
View File
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "RawMark API", "title": "RawMark API",
"version": "1", "version": "1",
"description": "RawMark单用户API。普通JSON统一{code,data,message},code=0成功;保留HTTP语义。登录会话固定30天;API令牌保留read/write权限。受保护API只接受Bearer,不接受Cookie。文件、下载、事件、204与OpenAPI文档不包装。" "description": "RawMark单用户API。普通JSON统一{code,data,message},code=0成功;保留HTTP语义。登录会话固定30天;API令牌保留read/write权限。受保护API只接受Bearer,不接受Cookie。文件、下载、事件、204与OpenAPI文档不包装。 响应提示支持Accept-Language: en或zh-CN(按q权重选择,缺省英文),Content-Language返回实际语言;状态码与业务码不变。/settings的locale为账户语言,支持en、zh-CN,null删除覆盖。"
}, },
"servers": [ "servers": [
{ {
+24 -24
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"mime" "mime"
@@ -15,6 +14,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"regexp" "regexp"
"strings" "strings"
@@ -48,7 +48,7 @@ func validPluginID(id string) bool {
func parsePluginManifest(data []byte) (pluginManifest, error) { func parsePluginManifest(data []byte) (pluginManifest, error) {
var m pluginManifest var m pluginManifest
if len(data) > 16<<10 || json.Unmarshal(data, &m) != nil || !validPluginID(m.ID) || strings.TrimSpace(m.Name) == "" || strings.TrimSpace(m.Version) == "" || m.APIVersion != 1 { if len(data) > 16<<10 || json.Unmarshal(data, &m) != nil || !validPluginID(m.ID) || strings.TrimSpace(m.Name) == "" || strings.TrimSpace(m.Version) == "" || m.APIVersion != 1 {
return m, fmt.Errorf("插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1") return m, i18n.New("invalid_plugin_manifest_a_valid_121d94")
} }
return m, nil return m, nil
} }
@@ -82,14 +82,14 @@ func (s *Server) openPlugin(id string) (*os.Root, pluginInfo, error) {
return nil, info, err return nil, info, err
} }
if m.ID != id { if m.ID != id {
return nil, info, fmt.Errorf("插件目录与 manifest id 不一致") return nil, info, i18n.New("plugin_directory_does_not_match_0eda90")
} }
rev, err := dir.ReadFile(".rawmark-revision") rev, err := dir.ReadFile(".rawmark-revision")
if err != nil { if err != nil {
return nil, info, err return nil, info, err
} }
if !pluginRevisionPattern.Match(rev) { if !pluginRevisionPattern.Match(rev) {
return nil, info, fmt.Errorf("插件安装信息无效") return nil, info, i18n.New("invalid_plugin_installation_information_3e7873")
} }
info.pluginManifest, info.Revision = m, string(rev) info.pluginManifest, info.Revision = m, string(rev)
ok = true ok = true
@@ -106,7 +106,7 @@ func (s *Server) enabledPlugins() (map[string]bool, error) {
err = json.Unmarshal([]byte(value), &enabled) err = json.Unmarshal([]byte(value), &enabled)
} }
if enabled == nil && err == nil { if enabled == nil && err == nil {
err = fmt.Errorf("插件启用配置无效") err = i18n.New("invalid_plugin_enabled_configuration_26d4bb")
} }
return enabled, err return enabled, err
} }
@@ -130,7 +130,7 @@ func (s *Server) setPluginEnabled(id string, enabled bool) error {
func (s *Server) pluginError(w http.ResponseWriter, r *http.Request, err error) { func (s *Server) pluginError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "插件不存在或安装不完整") writeError(w, 404, message(w, "plugin_not_found_or_installation_f577c6"))
} else { } else {
internalError(w, r, err) internalError(w, r, err)
} }
@@ -172,28 +172,28 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
var info pluginInfo var info pluginInfo
archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil { if err != nil {
return info, fmt.Errorf("无法读取插件 ZIP") return info, i18n.New("could_not_read_plugin_zip_922fd9")
} }
if len(archive.File) > 128 { if len(archive.File) > 128 {
return info, fmt.Errorf("插件最多包含 128 个文件或目录") return info, i18n.New("a_plugin_may_contain_at_41c105")
} }
seen := map[string]bool{} seen := map[string]bool{}
var total int64 var total int64
for _, file := range archive.File { for _, file := range archive.File {
name := strings.TrimSuffix(file.Name, "/") name := strings.TrimSuffix(file.Name, "/")
if !fs.ValidPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) { if !fs.ValidPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) {
return info, fmt.Errorf("插件包含非法路径、重复路径或特殊文件") return info, i18n.New("plugin_contains_invalid_paths_duplicate_6a1e05")
} }
seen[name] = true seen[name] = true
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
continue continue
} }
if file.UncompressedSize64 > maxPluginExpanded || total+int64(file.UncompressedSize64) > maxPluginExpanded { if file.UncompressedSize64 > maxPluginExpanded || total+int64(file.UncompressedSize64) > maxPluginExpanded {
return info, fmt.Errorf("插件解压后超过 20 MiB") return info, i18n.New("expanded_plugin_exceeds_mib_e06d7b")
} }
src, err := file.Open() src, err := file.Open()
if err != nil { if err != nil {
return info, fmt.Errorf("无法读取插件文件:%w", err) return info, i18n.New("could_not_read_plugin_file_016a17", err)
} }
content, readErr := io.ReadAll(io.LimitReader(src, maxPluginExpanded-total+1)) content, readErr := io.ReadAll(io.LimitReader(src, maxPluginExpanded-total+1))
closeErr := src.Close() closeErr := src.Close()
@@ -205,7 +205,7 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
} }
total += int64(len(content)) total += int64(len(content))
if total > maxPluginExpanded { if total > maxPluginExpanded {
return info, fmt.Errorf("插件解压后超过 20 MiB") return info, i18n.New("expanded_plugin_exceeds_mib_e06d7b")
} }
if name == "manifest.json" { if name == "manifest.json" {
info.pluginManifest, err = parsePluginManifest(content) info.pluginManifest, err = parsePluginManifest(content)
@@ -222,11 +222,11 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
} }
} }
if info.ID == "" || !seen["main.js"] { if info.ID == "" || !seen["main.js"] {
return info, fmt.Errorf("ZIP 根目录必须包含 manifest.json 和 main.js") return info, i18n.New("the_zip_root_must_contain_ee8588")
} }
main, err := os.Stat(filepath.Join(destination, "main.js")) main, err := os.Stat(filepath.Join(destination, "main.js"))
if err != nil || !main.Mode().IsRegular() { if err != nil || !main.Mode().IsRegular() {
return info, fmt.Errorf("main.js 必须为普通文件") return info, i18n.New("main_js_must_be_a_e84ee8")
} }
hash := sha256.Sum256(data) hash := sha256.Sum256(data)
info.Revision = hex.EncodeToString(hash[:]) info.Revision = hex.EncodeToString(hash[:])
@@ -236,18 +236,18 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) { func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
if s.PluginsDir == "" { if s.PluginsDir == "" {
writeError(w, 503, "插件目录未配置") writeError(w, 503, message(w, "plugin_directory_is_not_configured_5e0928"))
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10)) r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10))
if err := r.ParseMultipartForm(maxPluginZip); err != nil { if err := r.ParseMultipartForm(maxPluginZip); err != nil {
writeError(w, 400, "无法读取插件上传,请使用不超过 10 MiB 的 ZIP") writeError(w, 400, message(w, "could_not_read_plugin_upload_5fd8bc"))
return return
} }
defer r.MultipartForm.RemoveAll() defer r.MultipartForm.RemoveAll()
file, _, err := r.FormFile("file") file, _, err := r.FormFile("file")
if err != nil { if err != nil {
writeError(w, 400, "请选择插件 ZIP 文件") writeError(w, 400, message(w, "choose_a_plugin_zip_file_7360c5"))
return return
} }
defer file.Close() defer file.Close()
@@ -257,7 +257,7 @@ func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(data) > maxPluginZip { if len(data) > maxPluginZip {
writeError(w, 413, "插件 ZIP 超过 10 MiB") writeError(w, 413, message(w, "plugin_zip_exceeds_mib_046e37"))
return return
} }
s.pluginsMu.Lock() s.pluginsMu.Lock()
@@ -274,12 +274,12 @@ func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
defer os.RemoveAll(stage) defer os.RemoveAll(stage)
info, err := unpackPlugin(data, stage) info, err := unpackPlugin(data, stage)
if err != nil { if err != nil {
writeError(w, 400, err.Error()) writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return return
} }
target := filepath.Join(s.PluginsDir, info.ID) target := filepath.Join(s.PluginsDir, info.ID)
if _, err := os.Lstat(target); err == nil { if _, err := os.Lstat(target); err == nil {
writeError(w, 409, "同名插件已安装,请先卸载") writeError(w, 409, message(w, "a_plugin_with_this_id_1551cb"))
return return
} else if !errors.Is(err, fs.ErrNotExist) { } else if !errors.Is(err, fs.ErrNotExist) {
internalError(w, r, err) internalError(w, r, err)
@@ -305,7 +305,7 @@ func (s *Server) togglePlugin(w http.ResponseWriter, r *http.Request) {
return return
} }
if body.Enabled == nil { if body.Enabled == nil {
writeError(w, 400, "必须指定 enabled") writeError(w, 400, message(w, "enabled_must_be_specified_12873c"))
return return
} }
s.pluginsMu.Lock() s.pluginsMu.Lock()
@@ -358,7 +358,7 @@ func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) {
defer dir.Close() defer dir.Close()
name := r.PathValue("path") name := r.PathValue("path")
if r.PathValue("revision") != info.Revision || !fs.ValidPath(name) || strings.ContainsAny(name, "\\:") || strings.HasPrefix(name, ".") { if r.PathValue("revision") != info.Revision || !fs.ValidPath(name) || strings.ContainsAny(name, "\\:") || strings.HasPrefix(name, ".") {
writeError(w, 404, "插件资源不存在") writeError(w, 404, message(w, "plugin_resource_not_found_8e73f3"))
return return
} }
file, err := dir.Open(name) file, err := dir.Open(name)
@@ -373,7 +373,7 @@ func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) {
return return
} }
if !stat.Mode().IsRegular() { if !stat.Mode().IsRegular() {
writeError(w, 404, "插件资源不存在") writeError(w, 404, message(w, "plugin_resource_not_found_8e73f3"))
return return
} }
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -420,7 +420,7 @@ func (s *Server) putPluginData(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(body.Data) == 0 { if len(body.Data) == 0 {
writeError(w, 400, "必须提供 data") writeError(w, 400, message(w, "data_must_be_provided_0dc1bc"))
return return
} }
s.pluginsMu.Lock() s.pluginsMu.Lock()
+1 -1
View File
@@ -21,7 +21,7 @@ type response struct {
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
writeResponse(w, status, response{Code: 0, Data: v, Message: "成功"}) writeResponse(w, status, response{Code: 0, Data: v, Message: message(w, "success")})
} }
func writeFailure(w http.ResponseWriter, status, code int, message string, data any) { func writeFailure(w http.ResponseWriter, status, code int, message string, data any) {
writeResponse(w, status, response{Code: code, Data: data, Message: message}) writeResponse(w, status, response{Code: code, Data: data, Message: message})
+9 -9
View File
@@ -24,7 +24,7 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
switch body.Kind { switch body.Kind {
case "images", "fonts": case "images", "fonts":
if body.PluginID != "" || body.Revision != "" { if body.PluginID != "" || body.Revision != "" {
writeError(w, 400, "资源票据范围无效") writeError(w, 400, message(w, "invalid_resource_ticket_scope_7abece"))
return return
} }
case "events": case "events":
@@ -40,13 +40,13 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
} }
dir.Close() dir.Close()
if info.Revision != body.Revision { if info.Revision != body.Revision {
writeError(w, 409, "插件版本已改变,请刷新") writeError(w, 409, message(w, "plugin_version_changed_refresh_the_9fab75"))
return return
} }
t.Resource, t.Revision = body.PluginID, body.Revision t.Resource, t.Revision = body.PluginID, body.Revision
suffix = "plugins/" + body.PluginID + "/assets/" + body.Revision + "/" suffix = "plugins/" + body.PluginID + "/assets/" + body.Revision + "/"
default: default:
writeError(w, 400, "资源票据用途无效") writeError(w, 400, message(w, "invalid_resource_ticket_purpose_e266b8"))
return return
} }
plain := opaqueToken() plain := opaqueToken()
@@ -66,7 +66,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
return return
} }
if !ok { if !ok {
writeError(w, 401, "资源票据已失效") writeError(w, 401, message(w, "resource_ticket_has_expired_309b64"))
return return
} }
parent := principal{SessionHash: t.SessionHash, ExpiresAt: t.ExpiresAt} parent := principal{SessionHash: t.SessionHash, ExpiresAt: t.ExpiresAt}
@@ -76,7 +76,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
return return
} }
if !valid { if !valid {
writeError(w, 401, "登录已失效") writeError(w, 401, message(w, "login_session_has_expired_075903"))
return return
} }
r = withIdentity(r, parent) r = withIdentity(r, parent)
@@ -84,14 +84,14 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
switch { switch {
case kind == "images" && t.Kind == "images": case kind == "images" && t.Kind == "images":
if !vault.IsImage(name) { if !vault.IsImage(name) {
writeError(w, 403, "票据仅允许读取图片") writeError(w, 403, message(w, "this_ticket_only_permits_reading_f26030"))
return return
} }
s.raw(w, r) s.raw(w, r)
case kind == "fonts" && t.Kind == "fonts": case kind == "fonts" && t.Kind == "fonts":
id, ok := strings.CutSuffix(name, "/file") id, ok := strings.CutSuffix(name, "/file")
if !ok || strings.Contains(id, "/") { if !ok || strings.Contains(id, "/") {
writeError(w, 403, "字体票据范围不匹配") writeError(w, 403, message(w, "font_ticket_scope_does_not_95339a"))
return return
} }
r.SetPathValue("id", id) r.SetPathValue("id", id)
@@ -99,7 +99,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
case kind == "plugins" && t.Kind == "plugin": case kind == "plugins" && t.Kind == "plugin":
parts := strings.SplitN(name, "/", 4) parts := strings.SplitN(name, "/", 4)
if len(parts) != 4 || parts[0] != t.Resource || parts[1] != "assets" || parts[2] != t.Revision { if len(parts) != 4 || parts[0] != t.Resource || parts[1] != "assets" || parts[2] != t.Revision {
writeError(w, 403, "插件票据范围不匹配") writeError(w, 403, message(w, "plugin_ticket_scope_does_not_349049"))
return return
} }
r.SetPathValue("id", t.Resource) r.SetPathValue("id", t.Resource)
@@ -109,6 +109,6 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
case kind == "events" && t.Kind == "events" && name == "": case kind == "events" && t.Kind == "events" && name == "":
s.events(w, r) s.events(w, r)
default: default:
writeError(w, 403, "资源票据用途不匹配") writeError(w, 403, message(w, "resource_ticket_purpose_does_not_1f573e"))
} }
} }
+12 -7
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"rawmark/internal/i18n"
"rawmark/internal/auth" "rawmark/internal/auth"
"rawmark/internal/events" "rawmark/internal/events"
@@ -38,12 +39,12 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes)) data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes))
if err != nil { if err != nil {
writeError(w, 413, "设置过大") writeError(w, 413, message(w, "settings_are_too_large_8a580f"))
return return
} }
var patch map[string]json.RawMessage var patch map[string]json.RawMessage
if err := json.Unmarshal(data, &patch); err != nil || patch == nil { if err := json.Unmarshal(data, &patch); err != nil || patch == nil {
writeError(w, 400, "设置必须是 JSON 对象") writeError(w, 400, message(w, "settings_must_be_a_json_45b36b"))
return return
} }
s.settingsMu.Lock() s.settingsMu.Lock()
@@ -60,8 +61,12 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
settings[k] = v settings[k] = v
} }
} }
if err := validateLocale(settings); err != nil {
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return
}
if _, err := trashRetentionDays(settings); err != nil { if _, err := trashRetentionDays(settings); err != nil {
writeError(w, 400, err.Error()) writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return return
} }
merged, err := json.Marshal(settings) merged, err := json.Marshal(settings)
@@ -70,7 +75,7 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(merged) > maxJSONBytes { if len(merged) > maxJSONBytes {
writeError(w, 413, "设置过大") writeError(w, 413, message(w, "settings_are_too_large_8a580f"))
return return
} }
if err := s.store.SetSetting(settingsKey, string(merged)); err != nil { if err := s.store.SetSetting(settingsKey, string(merged)); err != nil {
@@ -89,11 +94,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
} }
switch err := s.auth.ChangePassword(clientIP(r), identity(r).Token, body.Current, body.Next); { switch err := s.auth.ChangePassword(clientIP(r), identity(r).Token, body.Current, body.Next); {
case errors.Is(err, auth.ErrRateLimited): case errors.Is(err, auth.ErrRateLimited):
writeError(w, 429, err.Error()) writeError(w, 429, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, auth.ErrBadCredentials): case errors.Is(err, auth.ErrBadCredentials):
writeFailure(w, 400, CodeCurrentPassword, "当前密码错误", nil) writeFailure(w, 400, CodeCurrentPassword, message(w, "current_password_is_incorrect_e0f0aa"), nil)
case errors.Is(err, auth.ErrWeakPassword): case errors.Is(err, auth.ErrWeakPassword):
writeError(w, 400, err.Error()) writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
case err != nil: case err != nil:
internalError(w, r, err) internalError(w, r, err)
default: default:
+3 -3
View File
@@ -38,13 +38,13 @@ func newStaticHandler(fsys fs.FS) http.Handler {
func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, 405, "不支持的请求方法") writeError(w, 405, message(w, "unsupported_request_method_bc2f08"))
return return
} }
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// 已移除的公开入口不能回退到应用首页。 // 已移除的公开入口不能回退到应用首页。
if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" { if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
writeError(w, 404, "页面不存在") writeError(w, 404, message(w, "page_not_found_e75737"))
return return
} }
f, err := h.load(name) f, err := h.load(name)
@@ -52,7 +52,7 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 前端路由:未知路径一律返回入口页面 // 前端路由:未知路径一律返回入口页面
name = "index.html" name = "index.html"
if f, err = h.load(name); err != nil { if f, err = h.load(name); err != nil {
writeError(w, 404, "前端资源缺失") writeError(w, 404, message(w, "frontend_resources_are_missing_2c9b3e"))
return return
} }
} }
+2 -2
View File
@@ -3,10 +3,10 @@ package api
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"strings" "strings"
"time" "time"
) )
@@ -44,7 +44,7 @@ func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) { 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"}}} usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
if dataDir == "" { if dataDir == "" {
return usage, fmt.Errorf("未配置应用数据目录") return usage, i18n.New("application_data_directory_is_not_a1f183")
} }
dataRoot, err := filepath.EvalSymlinks(dataDir) dataRoot, err := filepath.EvalSymlinks(dataDir)
if err != nil { if err != nil {
+13 -8
View File
@@ -4,11 +4,11 @@ import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"rawmark/internal/i18n"
"regexp" "regexp"
) )
@@ -54,14 +54,14 @@ func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
return nil, err return nil, err
} }
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return nil, fmt.Errorf("主题文件必须为普通文件") return nil, i18n.New("theme_file_must_be_a_1fe9df")
} }
data, err := io.ReadAll(io.LimitReader(f, limit+1)) data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil { if err != nil {
return nil, err return nil, err
} }
if int64(len(data)) > limit { if int64(len(data)) > limit {
return nil, fmt.Errorf("主题文件过大") return nil, i18n.New("theme_file_is_too_large_08667a")
} }
return data, nil return data, nil
} }
@@ -89,19 +89,21 @@ func (s *Server) readTheme(id string) (themeManifest, string, error) {
return manifest, "", err return manifest, "", err
} }
if err := json.Unmarshal(data, &manifest); err != nil { if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, "", fmt.Errorf("主题 %s 的 manifest.json 无法解析:%w", id, err) return manifest, "", i18n.New("could_not_parse_manifest_json_4bef07", id, err)
} }
if manifest.ID != id || manifest.Name == "" || manifest.Version == "" { if manifest.ID != id || manifest.Name == "" || manifest.Version == "" {
return manifest, "", fmt.Errorf("主题 %s 的 id、name 或 version 无效", id) return manifest, "", i18n.New("theme_s_has_an_invalid_8ed29c", id)
} }
css, err := readThemeFile(dir, "theme.css", 256<<10) css, err := readThemeFile(dir, "theme.css", 256<<10)
return manifest, string(css), err return manifest, string(css), err
} }
func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) { func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
themes := []themeManifest{{ID: "default", Name: "默认", Version: "1"}} themes := []themeManifest{{ID: "default", Name: message(w, "theme_default"), Version: "1"}}
for _, theme := range builtinThemes { for _, theme := range builtinThemes {
themes = append(themes, theme.themeManifest) manifest := theme.themeManifest
manifest.Name = message(w, "theme_"+theme.ID)
themes = append(themes, manifest)
} }
if s.ThemesDir != "" { if s.ThemesDir != "" {
entries, err := os.ReadDir(s.ThemesDir) entries, err := os.ReadDir(s.ThemesDir)
@@ -130,13 +132,16 @@ func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) { func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
manifest, css, err := s.readTheme(r.PathValue("id")) manifest, css, err := s.readTheme(r.PathValue("id"))
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "主题不存在或文件不完整") writeError(w, 404, message(w, "theme_not_found_or_files_3b0d5b"))
return return
} }
if err != nil { if err != nil {
internalError(w, r, err) internalError(w, r, err)
return return
} }
if _, _, ok := builtinTheme(manifest.ID); ok {
manifest.Name = message(w, "theme_"+manifest.ID)
}
writeJSON(w, 200, struct { writeJSON(w, 200, struct {
themeManifest themeManifest
CSS string `json:"css"` CSS string `json:"css"`
+4 -3
View File
@@ -4,6 +4,7 @@ import (
_ "embed" _ "embed"
"errors" "errors"
"net/http" "net/http"
"rawmark/internal/i18n"
"strings" "strings"
"rawmark/internal/auth" "rawmark/internal/auth"
@@ -26,12 +27,12 @@ func (s *Server) createToken(w http.ResponseWriter, r *http.Request) {
} }
name := strings.TrimSpace(body.Name) name := strings.TrimSpace(body.Name)
if name == "" || len([]rune(name)) > 100 { if name == "" || len([]rune(name)) > 100 {
writeError(w, 400, "名称不能为空且不超过 100 个字符") writeError(w, 400, message(w, "name_must_contain_between_and_7c157b"))
return return
} }
plain, t, err := s.auth.CreateAPIToken(name, body.Scope) plain, t, err := s.auth.CreateAPIToken(name, body.Scope)
if errors.Is(err, auth.ErrBadScope) { if errors.Is(err, auth.ErrBadScope) {
writeError(w, 400, err.Error()) writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return return
} }
if err != nil { if err != nil {
@@ -48,7 +49,7 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) {
return return
} }
if !ok { if !ok {
writeError(w, 404, "令牌不存在") writeError(w, 404, message(w, "token_not_found_da59eb"))
return return
} }
writeJSON(w, 200, map[string]bool{"ok": true}) writeJSON(w, 200, map[string]bool{"ok": true})
+6 -3
View File
@@ -4,9 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"rawmark/internal/i18n"
"time" "time"
) )
@@ -20,7 +20,7 @@ func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) {
} }
var days int64 var days int64
if err := json.Unmarshal(raw, &days); err != nil || days < 0 || days > maxRetentionDays { if err := json.Unmarshal(raw, &days); err != nil || days < 0 || days > maxRetentionDays {
return 0, fmt.Errorf("回收站保留天数必须为非负安全整数,0 表示永不自动删除") return 0, i18n.New("trash_retention_must_be_a_d6bd8f")
} }
return days, nil return days, nil
} }
@@ -31,6 +31,9 @@ func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
internalError(w, r, err) internalError(w, r, err)
return return
} }
for i := range result.Failed {
result.Failed[i].Error = i18n.RenderError(result.Failed[i].Cause, responseLocale(w))
}
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
@@ -53,7 +56,7 @@ func (s *Server) cleanupTrash(now time.Time) error {
return err return err
} }
if len(result.Failed) > 0 { if len(result.Failed) > 0 {
return fmt.Errorf("回收站清理部分失败: %v", result.Failed) return i18n.New("some_trash_items_could_not_0a4ca6", result.Failed)
} }
if len(result.Deleted) > 0 { if len(result.Deleted) > 0 {
slog.Info("已清理过期回收站条目", "count", len(result.Deleted)) slog.Info("已清理过期回收站条目", "count", len(result.Deleted))
+8 -8
View File
@@ -7,8 +7,8 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"rawmark/internal/i18n"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -38,11 +38,11 @@ const (
) )
var ( var (
ErrAlreadySetUp = errors.New("已完成初始化") ErrAlreadySetUp = i18n.New("setup_is_already_complete_222ec6")
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen) ErrWeakPassword = i18n.New("password_must_contain_at_least_decf04", MinPasswordLen)
ErrBadCredentials = errors.New("密码错误") ErrBadCredentials = i18n.New("incorrect_password_4585c8")
ErrRateLimited = errors.New("尝试次数过多,请稍后再试") ErrRateLimited = i18n.New("too_many_attempts_please_try_8bbf6d")
ErrBadScope = errors.New("权限范围只能是 read 或 write") ErrBadScope = i18n.New("scope_must_be_read_or_2cb466")
) )
type Manager struct { type Manager struct {
@@ -240,13 +240,13 @@ func hashPassword(password string) string {
func verifyPassword(encoded, password string) (bool, error) { func verifyPassword(encoded, password string) (bool, error) {
parts := strings.Split(encoded, "$") parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" { if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("密码哈希格式无法识别") return false, i18n.New("unrecognized_password_hash_format_2f5457")
} }
var mem uint32 var mem uint32
var t uint32 var t uint32
var p uint8 var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &p); err != nil { if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &p); err != nil {
return false, fmt.Errorf("密码哈希参数无法解析: %w", err) return false, i18n.New("could_not_parse_password_hash_3cd93b", err)
} }
b64 := base64.RawStdEncoding b64 := base64.RawStdEncoding
salt, err := b64.DecodeString(parts[4]) salt, err := b64.DecodeString(parts[4])
+30 -30
View File
@@ -9,11 +9,11 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -24,7 +24,7 @@ const MaxExpandedBytes int64 = 8 << 30
const MaxEntries = 100000 const MaxEntries = 100000
// ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。 // ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。
var ErrInvalidArchive = errors.New("备份内容无效") var ErrInvalidArchive = i18n.New("invalid_backup_contents_ecff07")
type Entry struct { type Entry struct {
Path string `json:"path"` Path string `json:"path"`
@@ -62,7 +62,7 @@ func validEntry(kind, name string) bool {
// Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。 // Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。
func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) { func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) {
if kind != "full" && kind != "settings" { if kind != "full" && kind != "settings" {
return fmt.Errorf("备份类型无效") return i18n.New("invalid_backup_type_f5ff70")
} }
f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil { if err != nil {
@@ -101,7 +101,7 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
return nil return nil
} }
if !validEntry(kind, name) || names[name] { if !validEntry(kind, name) || names[name] {
return fmt.Errorf("备份路径无效或重复: %s", name) return i18n.New("invalid_or_duplicate_backup_path_ce9c5b", name)
} }
names[name] = true names[name] = true
info, err := d.Info() info, err := d.Info()
@@ -109,11 +109,11 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
return err return err
} }
if !info.Mode().IsRegular() && !info.IsDir() { if !info.Mode().IsRegular() && !info.IsDir() {
return fmt.Errorf("备份不支持符号链接或特殊文件: %s", name) return i18n.New("backups_do_not_support_symlinks_872adf", name)
} }
e := Entry{Path: name, Dir: info.IsDir(), Modified: info.ModTime().UnixMilli()} e := Entry{Path: name, Dir: info.IsDir(), Modified: info.ModTime().UnixMilli()}
if len(manifest.Entries) >= MaxEntries { if len(manifest.Entries) >= MaxEntries {
return fmt.Errorf("备份文件数超过上限") return i18n.New("backup_file_count_exceeds_the_77ac37")
} }
h, err := zip.FileInfoHeader(info) h, err := zip.FileInfoHeader(info)
if err != nil { if err != nil {
@@ -141,17 +141,17 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
} }
total += e.Size total += e.Size
if total > MaxExpandedBytes { if total > MaxExpandedBytes {
return fmt.Errorf("备份展开大小超过 8 GiB") return i18n.New("expanded_backup_exceeds_gib_b38d18")
} }
if e.Size != info.Size() { if e.Size != info.Size() {
return fmt.Errorf("备份期间文件发生变化: %s", name) return i18n.New("a_file_changed_during_backup_a85244", name)
} }
after, err := os.Stat(full) after, err := os.Stat(full)
if err != nil { if err != nil {
return err return err
} }
if !os.SameFile(info, after) || !info.ModTime().Equal(after.ModTime()) || info.Size() != after.Size() { if !os.SameFile(info, after) || !info.ModTime().Equal(after.ModTime()) || info.Size() != after.Size() {
return fmt.Errorf("备份期间文件发生变化: %s", name) return i18n.New("a_file_changed_during_backup_a85244", name)
} }
e.SHA256 = hex.EncodeToString(sum.Sum(nil)) e.SHA256 = hex.EncodeToString(sum.Sum(nil))
} }
@@ -177,7 +177,7 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
return err return err
} }
if info.Size() > MaxArchiveBytes { if info.Size() > MaxArchiveBytes {
return fmt.Errorf("备份压缩大小超过 2 GiB") return i18n.New("compressed_backup_exceeds_gib_3962d5")
} }
return f.Sync() return f.Sync()
} }
@@ -188,52 +188,52 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
z, err := zip.OpenReader(archive) z, err := zip.OpenReader(archive)
if errors.Is(err, zip.ErrInsecurePath) { if errors.Is(err, zip.ErrInsecurePath) {
z.Close() z.Close()
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err) return manifest, i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
if errors.Is(err, zip.ErrFormat) { if errors.Is(err, zip.ErrFormat) {
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err) return manifest, i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
if err != nil { if err != nil {
return manifest, err return manifest, err
} }
defer z.Close() defer z.Close()
if len(z.File) > MaxEntries+1 { if len(z.File) > MaxEntries+1 {
return manifest, fmt.Errorf("%w:备份文件数超过上限", ErrInvalidArchive) return manifest, i18n.New("w_backup_file_count_exceeds_5ba1d4", ErrInvalidArchive)
} }
entries := map[string]*zip.File{} entries := map[string]*zip.File{}
for _, f := range z.File { for _, f := range z.File {
name := strings.TrimSuffix(f.Name, "/") name := strings.TrimSuffix(f.Name, "/")
if !validPath(name) || entries[name] != nil { if !validPath(name) || entries[name] != nil {
return manifest, fmt.Errorf("%w:归档路径无效或重复", ErrInvalidArchive) return manifest, i18n.New("w_invalid_or_duplicate_archive_c11ac2", ErrInvalidArchive)
} }
if !f.Mode().IsRegular() && !f.Mode().IsDir() { if !f.Mode().IsRegular() && !f.Mode().IsDir() {
return manifest, fmt.Errorf("%w:归档不允许符号链接或特殊文件", ErrInvalidArchive) return manifest, i18n.New("w_archives_cannot_contain_symlinks_fd836b", ErrInvalidArchive)
} }
entries[name] = f entries[name] = f
} }
m := entries["manifest.json"] m := entries["manifest.json"]
if m == nil || m.UncompressedSize64 > 32<<20 { if m == nil || m.UncompressedSize64 > 32<<20 {
return manifest, fmt.Errorf("%w:缺少有效备份清单", ErrInvalidArchive) return manifest, i18n.New("w_a_valid_backup_manifest_8905b1", ErrInvalidArchive)
} }
r, err := m.Open() r, err := m.Open()
if err != nil { if err != nil {
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err) return manifest, i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest) err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest)
if err != nil { if err != nil {
err = fmt.Errorf("%w:%w", ErrInvalidArchive, err) err = i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
err = errors.Join(err, r.Close()) err = errors.Join(err, r.Close())
if err != nil { if err != nil {
return manifest, err return manifest, err
} }
if manifest.Format != 1 || (manifest.Kind != "full" && manifest.Kind != "settings") || len(manifest.Entries) != len(entries)-1 { if manifest.Format != 1 || (manifest.Kind != "full" && manifest.Kind != "settings") || len(manifest.Entries) != len(entries)-1 {
return manifest, fmt.Errorf("%w:备份版本、类型或清单无效", ErrInvalidArchive) return manifest, i18n.New("w_invalid_backup_version_type_342220", ErrInvalidArchive)
} }
var expanded int64 var expanded int64
for _, e := range manifest.Entries { for _, e := range manifest.Entries {
if e.Size < 0 || e.Size > MaxExpandedBytes-expanded { if e.Size < 0 || e.Size > MaxExpandedBytes-expanded {
return manifest, fmt.Errorf("%w:备份展开大小超过 8 GiB", ErrInvalidArchive) return manifest, i18n.New("w_expanded_backup_exceeds_gib_8e4c5b", ErrInvalidArchive)
} }
expanded += e.Size expanded += e.Size
} }
@@ -242,7 +242,7 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
return manifest, err return manifest, err
} }
if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) { if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) {
return manifest, fmt.Errorf("磁盘空间不足:%w", syscall.ENOSPC) return manifest, i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC)
} }
seen := map[string]bool{} seen := map[string]bool{}
var total int64 var total int64
@@ -252,17 +252,17 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
} }
f := entries[e.Path] f := entries[e.Path]
if !validEntry(manifest.Kind, e.Path) || seen[e.Path] || f == nil || e.Size < 0 || e.Dir != f.FileInfo().IsDir() || uint64(e.Size) != f.UncompressedSize64 { if !validEntry(manifest.Kind, e.Path) || seen[e.Path] || f == nil || e.Size < 0 || e.Dir != f.FileInfo().IsDir() || uint64(e.Size) != f.UncompressedSize64 {
return manifest, fmt.Errorf("%w:备份清单与文件不符", ErrInvalidArchive) return manifest, i18n.New("w_backup_manifest_does_not_69c8ba", ErrInvalidArchive)
} }
seen[e.Path] = true seen[e.Path] = true
total += e.Size total += e.Size
if total > MaxExpandedBytes { if total > MaxExpandedBytes {
return manifest, fmt.Errorf("%w:备份展开大小超过 8 GiB", ErrInvalidArchive) return manifest, i18n.New("w_expanded_backup_exceeds_gib_8e4c5b", ErrInvalidArchive)
} }
target := filepath.Join(dest, filepath.FromSlash(e.Path)) target := filepath.Join(dest, filepath.FromSlash(e.Path))
if e.Dir { if e.Dir {
if e.Size != 0 { if e.Size != 0 {
return manifest, fmt.Errorf("%w:目录大小无效", ErrInvalidArchive) return manifest, i18n.New("w_invalid_directory_size_3ec5c4", ErrInvalidArchive)
} }
if err := os.MkdirAll(target, 0700); err != nil { if err := os.MkdirAll(target, 0700); err != nil {
return manifest, err return manifest, err
@@ -270,14 +270,14 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
continue continue
} }
if len(e.SHA256) != 64 { if len(e.SHA256) != 64 {
return manifest, fmt.Errorf("%w:文件校验值无效", ErrInvalidArchive) return manifest, i18n.New("w_invalid_file_checksum_ec0b61", ErrInvalidArchive)
} }
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
return manifest, err return manifest, err
} }
in, err := f.Open() in, err := f.Open()
if err != nil { if err != nil {
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err) return manifest, i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil { if err != nil {
@@ -288,14 +288,14 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
n, copyErr := io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, e.Size+1)) n, copyErr := io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, e.Size+1))
var corruptDeflate flate.CorruptInputError var corruptDeflate flate.CorruptInputError
if errors.Is(copyErr, zip.ErrChecksum) || errors.Is(copyErr, zip.ErrFormat) || errors.Is(copyErr, io.ErrUnexpectedEOF) || errors.As(copyErr, &corruptDeflate) { if errors.Is(copyErr, zip.ErrChecksum) || errors.Is(copyErr, zip.ErrFormat) || errors.Is(copyErr, io.ErrUnexpectedEOF) || errors.As(copyErr, &corruptDeflate) {
copyErr = fmt.Errorf("%w:%w", ErrInvalidArchive, copyErr) copyErr = i18n.New("w_w_f0fd7d", ErrInvalidArchive, copyErr)
} }
err = errors.Join(copyErr, in.Close(), out.Close()) err = errors.Join(copyErr, in.Close(), out.Close())
if err != nil { if err != nil {
return manifest, err return manifest, err
} }
if n != e.Size || hex.EncodeToString(sum.Sum(nil)) != e.SHA256 { if n != e.Size || hex.EncodeToString(sum.Sum(nil)) != e.SHA256 {
return manifest, fmt.Errorf("%w:文件校验失败: %s", ErrInvalidArchive, e.Path) return manifest, i18n.New("w_file_verification_failed_s_46b024", ErrInvalidArchive, e.Path)
} }
t := time.UnixMilli(e.Modified) t := time.UnixMilli(e.Modified)
if err := os.Chtimes(target, t, t); err != nil { if err := os.Chtimes(target, t, t); err != nil {
@@ -303,10 +303,10 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
} }
} }
if manifest.Kind == "full" && (!seen["app/rawmark.db"] || !seen["vault"] || entries["app/rawmark.db"].FileInfo().IsDir() || !entries["vault"].FileInfo().IsDir()) { if manifest.Kind == "full" && (!seen["app/rawmark.db"] || !seen["vault"] || entries["app/rawmark.db"].FileInfo().IsDir() || !entries["vault"].FileInfo().IsDir()) {
return manifest, fmt.Errorf("%w:实例备份缺少数据库或笔记库", ErrInvalidArchive) return manifest, i18n.New("w_instance_backup_is_missing_4e260c", ErrInvalidArchive)
} }
if manifest.Kind == "settings" && (!seen["settings.json"] || entries["settings.json"].FileInfo().IsDir()) { if manifest.Kind == "settings" && (!seen["settings.json"] || entries["settings.json"].FileInfo().IsDir()) {
return manifest, fmt.Errorf("%w:设置备份缺少设置", ErrInvalidArchive) return manifest, i18n.New("w_settings_backup_is_missing_46056b", ErrInvalidArchive)
} }
return manifest, nil return manifest, nil
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"sync" "sync"
) )
@@ -35,7 +36,7 @@ func (l *LogWriter) Write(p []byte) (int, error) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
if len(p) > 10<<20 { if len(p) > 10<<20 {
return 0, errors.New("单条日志超过大小上限") return 0, i18n.New("a_single_log_entry_exceeds_b1cdf6")
} }
if l.size+int64(len(p)) > 10<<20 { if l.size+int64(len(p)) > 10<<20 {
if err := l.file.Close(); err != nil { if err := l.file.Close(); err != nil {
+15 -15
View File
@@ -5,10 +5,10 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"strings" "strings"
"rawmark/internal/store" "rawmark/internal/store"
@@ -78,14 +78,14 @@ func CheckDatabase(file string) error {
header := make([]byte, 16) header := make([]byte, 16)
_, err = io.ReadFull(f, header) _, err = io.ReadFull(f, header)
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
err = fmt.Errorf("%w:%w", ErrInvalidArchive, err) err = i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
err = errors.Join(err, f.Close()) err = errors.Join(err, f.Close())
if err != nil { if err != nil {
return err return err
} }
if string(header) != "SQLite format 3\x00" { if string(header) != "SQLite format 3\x00" {
return fmt.Errorf("%w:备份数据库格式无效", ErrInvalidArchive) return i18n.New("w_invalid_backup_database_format_d45d60", ErrInvalidArchive)
} }
st, err := store.OpenExisting(file) st, err := store.OpenExisting(file)
if err != nil { if err != nil {
@@ -101,7 +101,7 @@ func CheckDatabase(file string) error {
return err return err
} }
} }
return fmt.Errorf("%w:%w", ErrInvalidArchive, err) return i18n.New("w_w_f0fd7d", ErrInvalidArchive, err)
} }
password, ok, err := st.Setting("auth.password") password, ok, err := st.Setting("auth.password")
if err != nil { if err != nil {
@@ -109,15 +109,15 @@ func CheckDatabase(file string) error {
} }
parts := strings.Split(password, "$") parts := strings.Split(password, "$")
if !ok || len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=4" { if !ok || len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=4" {
return fmt.Errorf("%w:备份密码格式不受支持", ErrInvalidArchive) return i18n.New("w_unsupported_backup_password_format_184d32", ErrInvalidArchive)
} }
salt, err := base64.RawStdEncoding.DecodeString(parts[4]) salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(salt) != 16 { if err != nil || len(salt) != 16 {
return fmt.Errorf("%w:备份密码盐无效", ErrInvalidArchive) return i18n.New("w_invalid_backup_password_salt_315eca", ErrInvalidArchive)
} }
key, err := base64.RawStdEncoding.DecodeString(parts[5]) key, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(key) != 32 { if err != nil || len(key) != 32 {
return fmt.Errorf("%w:备份密码校验值无效", ErrInvalidArchive) return i18n.New("w_invalid_backup_password_verification_17d1c1", ErrInvalidArchive)
} }
return nil return nil
} }
@@ -183,7 +183,7 @@ func CopyTree(src, dest string) error {
return err return err
} }
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return fmt.Errorf("不允许复制特殊文件") return i18n.New("special_files_cannot_be_copied_441836")
} }
if err = copyFile(p, target, 0600); err != nil { if err = copyFile(p, target, 0600); err != nil {
return err return err
@@ -272,17 +272,17 @@ func Recover(data, vault string) (err error) {
return err return err
} }
if record.State != "prepared" && record.State != "applying" && record.State != "verifying" { if record.State != "prepared" && record.State != "applying" && record.State != "verifying" {
return fmt.Errorf("恢复记录状态无效") return i18n.New("invalid_restore_record_state_eee063")
} }
// 记录由本机生成;仍限制其读取范围,避免错误记录读取任意文件。 // 记录由本机生成;仍限制其读取范围,避免错误记录读取任意文件。
if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) { if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) {
return fmt.Errorf("恢复记录路径无效") return i18n.New("invalid_restore_record_path_611881")
} }
if record.Kind == "settings" { if record.Kind == "settings" {
return recoverSettings(data, record) return recoverSettings(data, record)
} }
if record.State != "prepared" { if record.State != "prepared" {
err = fmt.Errorf("上次实例恢复被中断") err = i18n.New("the_previous_instance_restore_was_a9d480")
} }
if record.State == "prepared" { if record.State == "prepared" {
record.State = "applying" record.State = "applying"
@@ -304,13 +304,13 @@ func Recover(data, vault string) (err error) {
restoreErr = apply(data, vault, stage) restoreErr = apply(data, vault, stage)
} }
if restoreErr != nil { if restoreErr != nil {
return errors.Join(err, fmt.Errorf("实例回退失败: %w", restoreErr)) return errors.Join(err, i18n.New("instance_rollback_failed_w_80ec77", restoreErr))
} }
if removeErr := os.Remove(recordPath(data)); removeErr != nil { if removeErr := os.Remove(recordPath(data)); removeErr != nil {
return errors.Join(err, removeErr) return errors.Join(err, removeErr)
} }
if err != nil { if err != nil {
return fmt.Errorf("导入恢复失败,已回退原实例: %w", err) return i18n.New("restore_failed_original_instance_restored_c03846", err)
} }
return nil return nil
} }
@@ -329,7 +329,7 @@ func FinishRecovery(data string) error {
return err return err
} }
if record.State != "verifying" { if record.State != "verifying" {
return fmt.Errorf("恢复尚未完成初始化") return i18n.New("the_restored_instance_has_not_5ca7fb")
} }
if err = os.RemoveAll(filepath.Dir(record.Stage)); err != nil { if err = os.RemoveAll(filepath.Dir(record.Stage)); err != nil {
return err return err
@@ -383,5 +383,5 @@ func recoverSettings(data string, record RestoreRecord) error {
if err = os.Remove(recordPath(data)); err != nil { if err = os.Remove(recordPath(data)); err != nil {
return err return err
} }
return fmt.Errorf("设置导入被中断,已回退原设置和资源") return i18n.New("settings_import_was_interrupted_original_417ef5")
} }
+121
View File
@@ -0,0 +1,121 @@
// Package i18n keeps public messages structured until the HTTP boundary.
package i18n
import (
_ "embed"
"encoding/json"
"fmt"
"strconv"
"strings"
)
type Locale string
const English Locale = "en"
const Chinese Locale = "zh-CN"
//go:embed messages.json
var source []byte
var messages = func() map[string]map[Locale]string {
var values map[string]map[Locale]string
if err := json.Unmarshal(source, &values); err != nil {
panic(err)
}
return values
}()
// Negotiate supports the two shipped languages, honors quality weights, and
// defaults to English when the client omits or does not support a language.
func Negotiate(header string) Locale {
selected, best := English, float64(-1)
for _, item := range strings.Split(header, ",") {
parts := strings.Split(strings.TrimSpace(item), ";")
tag := strings.ToLower(strings.TrimSpace(parts[0]))
quality := float64(1)
for _, parameter := range parts[1:] {
if value, ok := strings.CutPrefix(strings.TrimSpace(parameter), "q="); ok {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 || parsed > 1 {
quality = 0
} else {
quality = parsed
}
}
}
var candidate Locale
switch {
case tag == "en" || strings.HasPrefix(tag, "en-") || tag == "*":
candidate = English
case tag == "zh" || tag == "zh-cn" || tag == "zh-hans" || tag == "zh-hans-cn":
candidate = Chinese
default:
continue
}
if quality > 0 && quality > best {
selected, best = candidate, quality
}
}
return selected
}
func Text(locale Locale, key string, args ...any) string {
translations, ok := messages[key]
if !ok {
panic("unknown message key: " + key)
}
format, ok := translations[locale]
if !ok {
panic("unsupported message locale: " + string(locale))
}
if len(args) == 0 {
return format
}
values := make([]any, len(args))
for i, arg := range args {
if err, ok := arg.(error); ok {
values[i] = RenderError(err, locale)
} else {
values[i] = arg
}
}
return fmt.Sprintf(strings.ReplaceAll(format, "%w", "%v"), values...)
}
type Error struct {
Key string
Args []any
causes []error
}
func New(key string, args ...any) *Error {
e := &Error{Key: key, Args: args}
// Preserve fmt.Errorf wrapping semantics: %v shows a cause but does not wrap it.
if len(args) > 0 {
formatted := fmt.Errorf(messages[key][Chinese], args...)
switch wrapped := formatted.(type) {
case interface{ Unwrap() []error }:
e.causes = wrapped.Unwrap()
case interface{ Unwrap() error }:
e.causes = []error{wrapped.Unwrap()}
}
}
return e
}
// Error preserves the established Chinese diagnostics used in logs and indexes.
func (e *Error) Error() string { return Text(Chinese, e.Key, e.Args...) }
func (e *Error) Unwrap() []error { return e.causes }
func RenderError(err error, locale Locale) string {
if localized, ok := err.(*Error); ok {
return Text(locale, localized.Key, localized.Args...)
}
if joined, ok := err.(interface{ Unwrap() []error }); ok {
parts := []string{}
for _, cause := range joined.Unwrap() {
parts = append(parts, RenderError(cause, locale))
}
return strings.Join(parts, "\n")
}
return err.Error()
}
+64
View File
@@ -0,0 +1,64 @@
package i18n
import (
"errors"
"fmt"
"reflect"
"regexp"
"strings"
"testing"
)
func TestNegotiate(t *testing.T) {
for _, test := range []struct {
header string
want Locale
}{
{"", English}, {"fr-FR", English}, {"en-US", English}, {"zh-CN", Chinese},
{"zh-Hans,en;q=0.8", Chinese}, {"zh-CN;q=0.5,en;q=0.9", English},
{"zh-CN;q=0,en", English}, {"zh-CN;q=invalid,en", English},
{"en;q=0.5,zh-CN;q=0.5", English},
} {
if got := Negotiate(test.header); got != test.want {
t.Errorf("%q: %q, want %q", test.header, got, test.want)
}
}
}
func TestCatalogParity(t *testing.T) {
arguments := regexp.MustCompile(`%[a-z]`)
for key, locales := range messages {
if locales[English] == "" || locales[Chinese] == "" {
t.Fatalf("missing translation: %s", key)
}
if !reflect.DeepEqual(arguments.FindAllString(locales[English], -1), arguments.FindAllString(locales[Chinese], -1)) {
t.Errorf("argument mismatch: %s", key)
}
}
}
func TestStructuredErrorsKeepIdentityAndLocalizeCauses(t *testing.T) {
sentinel := New("invalid_locale")
err := New("w_destination_is_not_a_f9de1c", sentinel)
if !errors.Is(err, sentinel) {
t.Fatal("wrapped cause lost")
}
var detail *Error
if !errors.As(err, &detail) {
t.Fatal("structured error type lost")
}
if got := RenderError(err, English); got != "Language must be en or zh-CN: destination is not a folder" {
t.Fatal(got)
}
if got := RenderError(err, Chinese); !strings.Contains(got, "语言必须为 en 或 zh-CN") {
t.Fatal(got)
}
original := errors.New("external diagnostic")
if errors.Is(New("operation_failed", original), original) {
t.Fatal("non-wrapping format changed error identity")
}
if got := RenderError(errors.Join(err, original), English); !strings.Contains(got, "external diagnostic") {
t.Fatal(got)
}
if fmt.Sprint(err) != RenderError(err, Chinese) {
t.Fatal("log language changed")
}
}
+970
View File
@@ -0,0 +1,970 @@
{
"api_endpoint_not_found_c49d86": {
"en": "API endpoint not found",
"zh-CN": "接口不存在"
},
"cross_site_request_rejected_5e7a50": {
"en": "Cross-site request rejected",
"zh-CN": "跨站请求被拒绝"
},
"invalid_request_icon_is_required_16149e": {
"en": "Invalid request: icon is required (null resets the default)",
"zh-CN": "请求格式错误:需要 icon(null 恢复默认)"
},
"note_is_too_large_86d890": {
"en": "Note is too large",
"zh-CN": "笔记过大"
},
"upload_file_is_missing_a2be00": {
"en": "Upload file is missing",
"zh-CN": "缺少上传文件"
},
"a_note_and_a_folder_8b62b5": {
"en": "A note and a folder cannot both be specified",
"zh-CN": "不能同时指定笔记和文件夹"
},
"images_must_not_exceed_mb_61d721": {
"en": "Images must not exceed 50 MB",
"zh-CN": "图片不能超过 50 MB"
},
"could_not_read_uploaded_file_4f04cc": {
"en": "Could not read uploaded file",
"zh-CN": "无法读取上传文件"
},
"invalid_request_format_2529b1": {
"en": "Invalid request format",
"zh-CN": "请求格式错误"
},
"internal_server_error_036549": {
"en": "Internal server error",
"zh-CN": "服务器内部错误"
},
"select_between_and_images_to_338e4e": {
"en": "Select between 1 and 1,000 images to clean up",
"zh-CN": "请选择 1~1000 张待清理图片"
},
"please_sign_in_48144f": {
"en": "Please sign in",
"zh-CN": "请先登录"
},
"credentials_are_invalid_or_expired_e1f63b": {
"en": "Credentials are invalid or expired",
"zh-CN": "登录凭据无效或已过期"
},
"this_endpoint_requires_a_login_d34b96": {
"en": "This endpoint requires a login session",
"zh-CN": "此接口需要登录会话"
},
"read_only_tokens_cannot_modify_6cf8e2": {
"en": "Read-only tokens cannot modify data",
"zh-CN": "只读令牌不能修改数据"
},
"the_verification_code_is_incorrect_6e789b": {
"en": "The verification code is incorrect or already used. Refresh it and try again",
"zh-CN": "验证码错误或已使用,请刷新后重试"
},
"the_verification_code_has_expired_0b3384": {
"en": "The verification code has expired. Refresh it and try again",
"zh-CN": "验证码已过期,请刷新后重试"
},
"invalid_font_installation_information_77cff8": {
"en": "Invalid font installation information",
"zh-CN": "字体安装信息无效"
},
"could_not_read_font_file_b7b543": {
"en": "Could not read font file. The limit is 30 MB per file",
"zh-CN": "无法读取字体文件,单个文件上限 30 MB"
},
"choose_a_font_file_4c95b9": {
"en": "Choose a font file",
"zh-CN": "请选择字体文件"
},
"font_file_exceeds_mb_cc001c": {
"en": "Font file exceeds 30 MB",
"zh-CN": "字体文件超过 30 MB"
},
"import_a_valid_ttf_otf_b67fd3": {
"en": "Import a valid TTF, OTF, WOFF, or WOFF2 font",
"zh-CN": "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件"
},
"font_directory_is_not_configured_73d32a": {
"en": "Font directory is not configured",
"zh-CN": "字体目录未配置"
},
"font_filename_is_invalid_or_a3fd5e": {
"en": "Font filename is invalid or too long",
"zh-CN": "字体文件名无效或过长"
},
"font_not_found_4383e9": {
"en": "Font not found",
"zh-CN": "字体不存在"
},
"a_font_must_be_a_5b8b51": {
"en": "A font must be a regular file",
"zh-CN": "字体必须为普通文件"
},
"maple_is_the_default_font_a8a571": {
"en": "Maple is the default font and cannot be deleted",
"zh-CN": "Maple 是默认字体,不可删除"
},
"font_disabled_but_file_cleanup_d8b892": {
"en": "Font disabled, but file cleanup failed: %w",
"zh-CN": "字体已停用,但文件清理失败:%w"
},
"image_url_must_be_a_391763": {
"en": "Image URL must be a public HTTP or HTTPS address",
"zh-CN": "图片地址必须是公网 HTTP 或 HTTPS 地址"
},
"only_png_jpeg_gif_and_a413cf": {
"en": "Only PNG, JPEG, GIF, and WebP images can be imported",
"zh-CN": "仅支持导入 PNG、JPEG、GIF 和 WebP 图片"
},
"too_many_redirects_from_the_6738ba": {
"en": "Too many redirects from the image source",
"zh-CN": "图片来源重定向次数过多"
},
"invalid_note_path_be69bd": {
"en": "Invalid note path",
"zh-CN": "所属笔记路径无效"
},
"could_not_download_image_b4a293": {
"en": "Could not download image",
"zh-CN": "下载图片失败"
},
"could_not_read_downloaded_image_4b7fdf": {
"en": "Could not read downloaded image",
"zh-CN": "读取下载图片失败"
},
"the_owning_path_must_be_867b53": {
"en": "The owning path must be a note file",
"zh-CN": "所属路径必须是笔记文件"
},
"total_import_size_must_not_23ac37": {
"en": "Total import size must not exceed 100 MB",
"zh-CN": "导入文件总量不能超过 100 MB"
},
"could_not_read_import_form_69f1c3": {
"en": "Could not read import form",
"zh-CN": "无法读取导入表单"
},
"choose_markdown_files_or_a_bea10e": {
"en": "Choose Markdown files or a ZIP",
"zh-CN": "请选择 Markdown 文件或一个 ZIP"
},
"maintenance_is_in_progress_please_64ef9d": {
"en": "Maintenance is in progress. Please try again later",
"zh-CN": "正在维护,请稍后重试"
},
"another_maintenance_operation_is_in_da1aa4": {
"en": "Another maintenance operation is in progress",
"zh-CN": "其他维护操作正在执行"
},
"invalid_maintenance_request_ef9c16": {
"en": "Invalid maintenance request",
"zh-CN": "维护请求无效"
},
"not_enough_disk_space_a53a70": {
"en": "Not enough disk space",
"zh-CN": "磁盘空间不足"
},
"invalid_backup_type_f5ff70": {
"en": "Invalid backup type",
"zh-CN": "备份类型无效"
},
"export_backup_e9d3dd": {
"en": "Export backup",
"zh-CN": "导出备份"
},
"export_note_data_45a4e1": {
"en": "Export note data",
"zh-CN": "导出笔记数据"
},
"w_a_single_setting_exceeds_c7dccb": {
"en": "%w: a single setting exceeds 1 MiB",
"zh-CN": "%w:单项设置超过 1 MiB"
},
"w_preferences_must_be_an_8d2724": {
"en": "%w: preferences must be an object",
"zh-CN": "%w:偏好设置必须为对象"
},
"w_invalid_shortcut_format_0acab4": {
"en": "%w: invalid shortcut format",
"zh-CN": "%w:快捷键格式无效"
},
"w_invalid_plugin_id_7f3512": {
"en": "%w: invalid plugin ID",
"zh-CN": "%w:插件 ID 无效"
},
"w_invalid_plugin_data_1f5fe3": {
"en": "%w: invalid plugin data",
"zh-CN": "%w:插件数据无效"
},
"w_the_settings_package_contains_e70b59": {
"en": "%w: the settings package contains disallowed settings",
"zh-CN": "%w:设置包包含不允许的设置项"
},
"w_settings_package_content_exceeds_baf8eb": {
"en": "%w: settings package content exceeds 32 MiB",
"zh-CN": "%w:设置包设置内容超过 32 MiB"
},
"w_invalid_settings_package_format_2e7bee": {
"en": "%w: invalid settings package format",
"zh-CN": "%w:设置包格式无效"
},
"confirm_settings_import_first_e89aa3": {
"en": "Confirm settings import first",
"zh-CN": "请先确认导入设置"
},
"import_settings_1a80d4": {
"en": "Import settings",
"zh-CN": "导入设置"
},
"confirm_reset_first_22ae3d": {
"en": "Confirm reset first",
"zh-CN": "请先确认重置"
},
"reset_settings_9a70ef": {
"en": "Reset settings",
"zh-CN": "重置设置"
},
"invalid_index_operation_be898b": {
"en": "Invalid index operation",
"zh-CN": "索引操作无效"
},
"index_maintenance_676706": {
"en": "Index maintenance",
"zh-CN": "索引维护"
},
"log_files_are_not_enabled_dcf4df": {
"en": "Log files are not enabled for this service",
"zh-CN": "当前服务尚未启用日志文件"
},
"export_system_logs_d05855": {
"en": "Export system logs",
"zh-CN": "导出系统日志"
},
"w_invalid_upload_id_23d358": {
"en": "%w: invalid upload ID",
"zh-CN": "%w:上传 ID 无效"
},
"w_upload_does_not_exist_918d8d": {
"en": "%w: upload does not exist or has expired",
"zh-CN": "%w:上传不存在或已过期"
},
"w_upload_does_not_belong_c4d06d": {
"en": "%w: upload does not belong to the current session",
"zh-CN": "%w:上传不属于当前会话"
},
"not_enough_disk_space_w_a39b20": {
"en": "Not enough disk space: %w",
"zh-CN": "磁盘空间不足:%w"
},
"choose_a_valid_backup_with_51ba41": {
"en": "Choose a valid backup with a compressed size no greater than 2 GiB",
"zh-CN": "请选择有效备份,压缩大小上限 2 GiB"
},
"prepare_upload_ed0bd9": {
"en": "Prepare upload",
"zh-CN": "准备上传"
},
"invalid_upload_offset_3bdbf1": {
"en": "Invalid upload offset",
"zh-CN": "上传偏移无效"
},
"upload_backup_83b1a9": {
"en": "Upload backup",
"zh-CN": "上传备份"
},
"w_the_resource_directory_contains_3898a5": {
"en": "%w: the resource directory contains a non-directory entry",
"zh-CN": "%w:资源目录内包含非目录条目"
},
"validate_backup_09df97": {
"en": "Validate backup",
"zh-CN": "校验备份"
},
"cancel_upload_a24836": {
"en": "Cancel upload",
"zh-CN": "取消上传"
},
"confirm_replacing_instance_data_and_52f3ca": {
"en": "Confirm replacing instance data and signing in again first",
"zh-CN": "请先确认替换实例数据并重新登录"
},
"this_service_does_not_support_a9a8ba": {
"en": "This service does not support instance restore",
"zh-CN": "当前服务不支持实例恢复"
},
"restore_full_instance_9b4ee0": {
"en": "Restore full instance",
"zh-CN": "恢复完整实例"
},
"clean_temporary_files_af91ae": {
"en": "Clean temporary files",
"zh-CN": "清理临时文件"
},
"invalid_plugin_manifest_a_valid_121d94": {
"en": "Invalid plugin manifest. A valid id, name, version, and apiVersion: 1 are required",
"zh-CN": "插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1"
},
"plugin_directory_does_not_match_0eda90": {
"en": "Plugin directory does not match the manifest ID",
"zh-CN": "插件目录与 manifest id 不一致"
},
"invalid_plugin_installation_information_3e7873": {
"en": "Invalid plugin installation information",
"zh-CN": "插件安装信息无效"
},
"invalid_plugin_enabled_configuration_26d4bb": {
"en": "Invalid plugin enabled configuration",
"zh-CN": "插件启用配置无效"
},
"plugin_not_found_or_installation_f577c6": {
"en": "Plugin not found or installation is incomplete",
"zh-CN": "插件不存在或安装不完整"
},
"could_not_read_plugin_zip_922fd9": {
"en": "Could not read plugin ZIP",
"zh-CN": "无法读取插件 ZIP"
},
"a_plugin_may_contain_at_41c105": {
"en": "A plugin may contain at most 128 files or directories",
"zh-CN": "插件最多包含 128 个文件或目录"
},
"plugin_contains_invalid_paths_duplicate_6a1e05": {
"en": "Plugin contains invalid paths, duplicate paths, or special files",
"zh-CN": "插件包含非法路径、重复路径或特殊文件"
},
"expanded_plugin_exceeds_mib_e06d7b": {
"en": "Expanded plugin exceeds 20 MiB",
"zh-CN": "插件解压后超过 20 MiB"
},
"could_not_read_plugin_file_016a17": {
"en": "Could not read plugin file: %w",
"zh-CN": "无法读取插件文件:%w"
},
"the_zip_root_must_contain_ee8588": {
"en": "The ZIP root must contain manifest.json and main.js",
"zh-CN": "ZIP 根目录必须包含 manifest.json 和 main.js"
},
"main_js_must_be_a_e84ee8": {
"en": "main.js must be a regular file",
"zh-CN": "main.js 必须为普通文件"
},
"plugin_directory_is_not_configured_5e0928": {
"en": "Plugin directory is not configured",
"zh-CN": "插件目录未配置"
},
"could_not_read_plugin_upload_5fd8bc": {
"en": "Could not read plugin upload. Use a ZIP no larger than 10 MiB",
"zh-CN": "无法读取插件上传,请使用不超过 10 MiB 的 ZIP"
},
"choose_a_plugin_zip_file_7360c5": {
"en": "Choose a plugin ZIP file",
"zh-CN": "请选择插件 ZIP 文件"
},
"plugin_zip_exceeds_mib_046e37": {
"en": "Plugin ZIP exceeds 10 MiB",
"zh-CN": "插件 ZIP 超过 10 MiB"
},
"a_plugin_with_this_id_1551cb": {
"en": "A plugin with this ID is already installed. Uninstall it first",
"zh-CN": "同名插件已安装,请先卸载"
},
"enabled_must_be_specified_12873c": {
"en": "enabled must be specified",
"zh-CN": "必须指定 enabled"
},
"plugin_resource_not_found_8e73f3": {
"en": "Plugin resource not found",
"zh-CN": "插件资源不存在"
},
"data_must_be_provided_0dc1bc": {
"en": "data must be provided",
"zh-CN": "必须提供 data"
},
"invalid_resource_ticket_scope_7abece": {
"en": "Invalid resource ticket scope",
"zh-CN": "资源票据范围无效"
},
"plugin_version_changed_refresh_the_9fab75": {
"en": "Plugin version changed. Refresh the page",
"zh-CN": "插件版本已改变,请刷新"
},
"invalid_resource_ticket_purpose_e266b8": {
"en": "Invalid resource ticket purpose",
"zh-CN": "资源票据用途无效"
},
"resource_ticket_has_expired_309b64": {
"en": "Resource ticket has expired",
"zh-CN": "资源票据已失效"
},
"login_session_has_expired_075903": {
"en": "Login session has expired",
"zh-CN": "登录已失效"
},
"this_ticket_only_permits_reading_f26030": {
"en": "This ticket only permits reading images",
"zh-CN": "票据仅允许读取图片"
},
"font_ticket_scope_does_not_95339a": {
"en": "Font ticket scope does not match",
"zh-CN": "字体票据范围不匹配"
},
"plugin_ticket_scope_does_not_349049": {
"en": "Plugin ticket scope does not match",
"zh-CN": "插件票据范围不匹配"
},
"resource_ticket_purpose_does_not_1f573e": {
"en": "Resource ticket purpose does not match",
"zh-CN": "资源票据用途不匹配"
},
"settings_are_too_large_8a580f": {
"en": "Settings are too large",
"zh-CN": "设置过大"
},
"settings_must_be_a_json_45b36b": {
"en": "Settings must be a JSON object",
"zh-CN": "设置必须是 JSON 对象"
},
"current_password_is_incorrect_e0f0aa": {
"en": "Current password is incorrect",
"zh-CN": "当前密码错误"
},
"unsupported_request_method_bc2f08": {
"en": "Unsupported request method",
"zh-CN": "不支持的请求方法"
},
"page_not_found_e75737": {
"en": "Page not found",
"zh-CN": "页面不存在"
},
"frontend_resources_are_missing_2c9b3e": {
"en": "Frontend resources are missing",
"zh-CN": "前端资源缺失"
},
"application_data_directory_is_not_a1f183": {
"en": "Application data directory is not configured",
"zh-CN": "未配置应用数据目录"
},
"theme_file_must_be_a_1fe9df": {
"en": "Theme file must be a regular file",
"zh-CN": "主题文件必须为普通文件"
},
"theme_file_is_too_large_08667a": {
"en": "Theme file is too large",
"zh-CN": "主题文件过大"
},
"could_not_parse_manifest_json_4bef07": {
"en": "Could not parse manifest.json for theme %s: %w",
"zh-CN": "主题 %s 的 manifest.json 无法解析:%w"
},
"theme_s_has_an_invalid_8ed29c": {
"en": "Theme %s has an invalid ID, name, or version",
"zh-CN": "主题 %s 的 id、name 或 version 无效"
},
"theme_not_found_or_files_3b0d5b": {
"en": "Theme not found or files are incomplete",
"zh-CN": "主题不存在或文件不完整"
},
"name_must_contain_between_and_7c157b": {
"en": "Name must contain between 1 and 100 characters",
"zh-CN": "名称不能为空且不超过 100 个字符"
},
"token_not_found_da59eb": {
"en": "Token not found",
"zh-CN": "令牌不存在"
},
"trash_retention_must_be_a_d6bd8f": {
"en": "Trash retention must be a non-negative safe integer. 0 disables automatic deletion",
"zh-CN": "回收站保留天数必须为非负安全整数,0 表示永不自动删除"
},
"some_trash_items_could_not_0a4ca6": {
"en": "Some trash items could not be cleaned: %v",
"zh-CN": "回收站清理部分失败: %v"
},
"setup_is_already_complete_222ec6": {
"en": "Setup is already complete",
"zh-CN": "已完成初始化"
},
"password_must_contain_at_least_decf04": {
"en": "Password must contain at least %d characters",
"zh-CN": "密码至少 %d 位"
},
"incorrect_password_4585c8": {
"en": "Incorrect password",
"zh-CN": "密码错误"
},
"too_many_attempts_please_try_8bbf6d": {
"en": "Too many attempts. Please try again later",
"zh-CN": "尝试次数过多,请稍后再试"
},
"scope_must_be_read_or_2cb466": {
"en": "Scope must be read or write",
"zh-CN": "权限范围只能是 read 或 write"
},
"unrecognized_password_hash_format_2f5457": {
"en": "Unrecognized password hash format",
"zh-CN": "密码哈希格式无法识别"
},
"could_not_parse_password_hash_3cd93b": {
"en": "Could not parse password hash parameters: %w",
"zh-CN": "密码哈希参数无法解析: %w"
},
"invalid_backup_contents_ecff07": {
"en": "Invalid backup contents",
"zh-CN": "备份内容无效"
},
"invalid_or_duplicate_backup_path_ce9c5b": {
"en": "Invalid or duplicate backup path: %s",
"zh-CN": "备份路径无效或重复: %s"
},
"backups_do_not_support_symlinks_872adf": {
"en": "Backups do not support symlinks or special files: %s",
"zh-CN": "备份不支持符号链接或特殊文件: %s"
},
"backup_file_count_exceeds_the_77ac37": {
"en": "Backup file count exceeds the limit",
"zh-CN": "备份文件数超过上限"
},
"expanded_backup_exceeds_gib_b38d18": {
"en": "Expanded backup exceeds 8 GiB",
"zh-CN": "备份展开大小超过 8 GiB"
},
"a_file_changed_during_backup_a85244": {
"en": "A file changed during backup: %s",
"zh-CN": "备份期间文件发生变化: %s"
},
"compressed_backup_exceeds_gib_3962d5": {
"en": "Compressed backup exceeds 2 GiB",
"zh-CN": "备份压缩大小超过 2 GiB"
},
"w_backup_file_count_exceeds_5ba1d4": {
"en": "%w: backup file count exceeds the limit",
"zh-CN": "%w:备份文件数超过上限"
},
"w_invalid_or_duplicate_archive_c11ac2": {
"en": "%w: invalid or duplicate archive path",
"zh-CN": "%w:归档路径无效或重复"
},
"w_archives_cannot_contain_symlinks_fd836b": {
"en": "%w: archives cannot contain symlinks or special files",
"zh-CN": "%w:归档不允许符号链接或特殊文件"
},
"w_a_valid_backup_manifest_8905b1": {
"en": "%w: a valid backup manifest is missing",
"zh-CN": "%w:缺少有效备份清单"
},
"w_invalid_backup_version_type_342220": {
"en": "%w: invalid backup version, type, or manifest",
"zh-CN": "%w:备份版本、类型或清单无效"
},
"w_expanded_backup_exceeds_gib_8e4c5b": {
"en": "%w: expanded backup exceeds 8 GiB",
"zh-CN": "%w:备份展开大小超过 8 GiB"
},
"w_backup_manifest_does_not_69c8ba": {
"en": "%w: backup manifest does not match files",
"zh-CN": "%w:备份清单与文件不符"
},
"w_invalid_directory_size_3ec5c4": {
"en": "%w: invalid directory size",
"zh-CN": "%w:目录大小无效"
},
"w_invalid_file_checksum_ec0b61": {
"en": "%w: invalid file checksum",
"zh-CN": "%w:文件校验值无效"
},
"w_file_verification_failed_s_46b024": {
"en": "%w: file verification failed: %s",
"zh-CN": "%w:文件校验失败: %s"
},
"w_instance_backup_is_missing_4e260c": {
"en": "%w: instance backup is missing the database or vault",
"zh-CN": "%w:实例备份缺少数据库或笔记库"
},
"w_settings_backup_is_missing_46056b": {
"en": "%w: settings backup is missing settings",
"zh-CN": "%w:设置备份缺少设置"
},
"a_single_log_entry_exceeds_b1cdf6": {
"en": "A single log entry exceeds the size limit",
"zh-CN": "单条日志超过大小上限"
},
"w_invalid_backup_database_format_d45d60": {
"en": "%w: invalid backup database format",
"zh-CN": "%w:备份数据库格式无效"
},
"w_unsupported_backup_password_format_184d32": {
"en": "%w: unsupported backup password format",
"zh-CN": "%w:备份密码格式不受支持"
},
"w_invalid_backup_password_salt_315eca": {
"en": "%w: invalid backup password salt",
"zh-CN": "%w:备份密码盐无效"
},
"w_invalid_backup_password_verification_17d1c1": {
"en": "%w: invalid backup password verification value",
"zh-CN": "%w:备份密码校验值无效"
},
"special_files_cannot_be_copied_441836": {
"en": "Special files cannot be copied",
"zh-CN": "不允许复制特殊文件"
},
"invalid_restore_record_state_eee063": {
"en": "Invalid restore record state",
"zh-CN": "恢复记录状态无效"
},
"invalid_restore_record_path_611881": {
"en": "Invalid restore record path",
"zh-CN": "恢复记录路径无效"
},
"the_previous_instance_restore_was_a9d480": {
"en": "The previous instance restore was interrupted",
"zh-CN": "上次实例恢复被中断"
},
"instance_rollback_failed_w_80ec77": {
"en": "Instance rollback failed: %w",
"zh-CN": "实例回退失败: %w"
},
"restore_failed_original_instance_restored_c03846": {
"en": "Restore failed. Original instance restored: %w",
"zh-CN": "导入恢复失败,已回退原实例: %w"
},
"the_restored_instance_has_not_5ca7fb": {
"en": "The restored instance has not completed setup",
"zh-CN": "恢复尚未完成初始化"
},
"settings_import_was_interrupted_original_417ef5": {
"en": "Settings import was interrupted. Original settings and resources restored",
"zh-CN": "设置导入被中断,已回退原设置和资源"
},
"could_not_initialize_search_index_9155c3": {
"en": "Could not initialize search index: %w",
"zh-CN": "初始化搜索索引失败: %w"
},
"invalid_format_for_setting_s_745c8c": {
"en": "Invalid format for setting %s",
"zh-CN": "设置 %s 格式无效"
},
"settings_contain_an_invalid_entry_90c647": {
"en": "Settings contain an invalid entry",
"zh-CN": "设置包含无效设置项"
},
"backup_database_integrity_check_failed_5adb6a": {
"en": "Backup database integrity check failed",
"zh-CN": "备份数据库完整性检查失败"
},
"could_not_initialize_database_w_208f9e": {
"en": "Could not initialize database: %w",
"zh-CN": "初始化数据库失败: %w"
},
"w_invalid_metadata_in_s_478c93": {
"en": "%w: invalid metadata in %s prevents checking vault references: %s",
"zh-CN": "%w:%s 的元数据异常,无法确认全库引用:%s"
},
"w_choose_content_to_export_170d5e": {
"en": "%w: choose content to export",
"zh-CN": "%w:请选择导出内容"
},
"w_invalid_attachment_address_s_8b6ace": {
"en": "%w: invalid attachment address %s",
"zh-CN": "%w:附件地址无效 %s"
},
"attachment_s_w_7bc269": {
"en": "Attachment %s: %w",
"zh-CN": "附件 %s:%w"
},
"w_choose_a_file_with_8410e9": {
"en": "%w: choose a file with the same extension as the original image",
"zh-CN": "%w:请选择与原图片扩展名相同的文件"
},
"w_image_cannot_be_empty_a9a272": {
"en": "%w: image cannot be empty",
"zh-CN": "%w:图片不能为空"
},
"invalid_import_contents_ed556a": {
"en": "Invalid import contents",
"zh-CN": "导入内容无效"
},
"w_choose_markdown_files_or_bcaa52": {
"en": "%w: choose Markdown files or a ZIP",
"zh-CN": "%w:请选择 Markdown 文件或一个 ZIP"
},
"w_invalid_import_folder_name_a10885": {
"en": "%w: invalid import folder name",
"zh-CN": "%w:导入文件夹名称无效"
},
"w_plain_markdown_files_do_08b920": {
"en": "%w: plain Markdown files do not use a wrapper folder",
"zh-CN": "%w:普通 Markdown 不使用外层文件夹"
},
"could_not_clean_the_temporary_212214": {
"en": "Could not clean the temporary import directory: %w",
"zh-CN": "清理导入临时目录失败:%w"
},
"w_zip_contains_an_invalid_7bcf46": {
"en": "%w: ZIP contains an invalid path",
"zh-CN": "%w:ZIP 含有非法路径"
},
"w_duplicate_path_408694": {
"en": "%w: duplicate path",
"zh-CN": "%w:存在重复路径"
},
"w_file_and_directory_paths_eb0b28": {
"en": "%w: file and directory paths conflict",
"zh-CN": "%w:文件与目录路径冲突"
},
"w_could_not_read_zip_d4a777": {
"en": "%w: could not read ZIP: %w",
"zh-CN": "%w:无法读取 ZIP:%w"
},
"w_zip_cannot_contain_symlinks_5f596f": {
"en": "%w: ZIP cannot contain symlinks or special files",
"zh-CN": "%w:ZIP 不允许符号链接或特殊文件"
},
"w_could_not_read_zip_8158b9": {
"en": "%w: could not read ZIP entry: %w",
"zh-CN": "%w:无法读取 ZIP 条目:%w"
},
"w_zip_content_is_damaged_6323c7": {
"en": "%w: ZIP content is damaged: %w",
"zh-CN": "%w:ZIP 内容损坏:%w"
},
"w_only_md_files_or_6f1a4f": {
"en": "%w: only .md files or a single ZIP are accepted",
"zh-CN": "%w:只接受 .md 文件或单个 ZIP"
},
"w_no_importable_markdown_notes_3ad0b8": {
"en": "%w: no importable Markdown notes found",
"zh-CN": "%w:没有可导入的 Markdown 笔记"
},
"w_notes_cannot_be_inside_744972": {
"en": "%w: notes cannot be inside .assets attachment directories",
"zh-CN": "%w:笔记不能放在 .assets 附件目录内"
},
"w_markdown_must_be_utf_ce0d80": {
"en": "%w: Markdown must be UTF-8 text",
"zh-CN": "%w:Markdown 必须是 UTF-8 文本"
},
"w_invalid_metadata_in_s_cc1d10": {
"en": "%w: invalid metadata in %s prevents checking image references",
"zh-CN": "%w:%s 的元数据异常,无法确认图片引用"
},
"w_destination_is_not_a_f9de1c": {
"en": "%w: destination is not a folder",
"zh-CN": "%w:目标不是文件夹"
},
"w_s_choose_a_different_ac4d15": {
"en": "%w: %s; choose a different name",
"zh-CN": "%w:%s,请修改名称"
},
"import_rollback_failed_w_50748f": {
"en": "Import rollback failed: %w",
"zh-CN": "回滚导入失败:%w"
},
"retention_days_cannot_be_negative_f37c9b": {
"en": "Retention days cannot be negative",
"zh-CN": "保留天数不能为负数"
},
"invalid_icon_59d610": {
"en": "Invalid icon",
"zh-CN": "非法图标"
},
"file_tree_icon_metadata_is_49b5aa": {
"en": "File tree icon metadata is damaged: %w",
"zh-CN": "文件树图标元数据损坏: %w"
},
"file_tree_icon_metadata_must_54f104": {
"en": "File tree icon metadata must be an object",
"zh-CN": "文件树图标元数据必须是对象"
},
"file_tree_icon_metadata_contains_eb66b6": {
"en": "File tree icon metadata contains invalid paths or icons",
"zh-CN": "文件树图标元数据含非法路径或图标"
},
"invalid_path_ec93bf": {
"en": "Invalid path",
"zh-CN": "非法路径"
},
"file_not_found_3703e4": {
"en": "File not found",
"zh-CN": "文件不存在"
},
"destination_already_exists_9511c3": {
"en": "Destination already exists",
"zh-CN": "目标已存在"
},
"file_is_too_large_5bfcab": {
"en": "File is too large",
"zh-CN": "文件过大"
},
"could_not_roll_back_the_d7c4fc": {
"en": "Could not roll back the original image: %w",
"zh-CN": "回滚图片原件失败: %w"
},
"could_not_remove_the_copied_e96efd": {
"en": "Could not remove the copied image: %w",
"zh-CN": "撤回图片副本失败: %w"
},
"could_not_roll_back_the_f89084": {
"en": "Could not roll back the old attachment folder: %w",
"zh-CN": "回滚旧附件目录失败: %w"
},
"could_not_roll_back_note_8554a3": {
"en": "Could not roll back note references: %w",
"zh-CN": "回滚笔记引用失败: %w"
},
"could_not_roll_back_the_36ec22": {
"en": "Could not roll back the note move: %w",
"zh-CN": "回滚笔记移动失败: %w"
},
"could_not_move_image_folder_c39928": {
"en": "Could not move image folder: %w",
"zh-CN": "移动图片目录失败: %w"
},
"could_not_roll_back_trashed_b37d4f": {
"en": "Could not roll back trashed images: %w",
"zh-CN": "回滚回收站图片失败: %w"
},
"could_not_roll_back_trashed_3758a4": {
"en": "Could not roll back trashed note: %w",
"zh-CN": "回滚回收站笔记失败: %w"
},
"could_not_move_image_folder_7c4620": {
"en": "Could not move image folder to trash: %w",
"zh-CN": "移动图片目录到回收站失败: %w"
},
"trash_item_s_has_damaged_ba382a": {
"en": "Trash item %s has damaged metadata: %w",
"zh-CN": "回收站条目 %s 元数据损坏: %w"
},
"trash_item_s_has_invalid_006492": {
"en": "Trash item %s has invalid metadata",
"zh-CN": "回收站条目 %s 元数据无效"
},
"trash_item_s_has_damaged_856237": {
"en": "Trash item %s has damaged icon metadata",
"zh-CN": "回收站条目 %s 图标元数据损坏"
},
"could_not_roll_back_restored_952073": {
"en": "Could not roll back restored images: %w",
"zh-CN": "回滚恢复图片失败: %w"
},
"could_not_roll_back_restored_16b803": {
"en": "Could not roll back restored note: %w",
"zh-CN": "回滚恢复笔记失败: %w"
},
"could_not_restore_image_folder_f6e087": {
"en": "Could not restore image folder: %w",
"zh-CN": "恢复图片目录失败: %w"
},
"w_choose_an_image_file_544069": {
"en": "%w: choose an image file",
"zh-CN": "%w:请选择图片文件"
},
"could_not_read_note_s_708ab5": {
"en": "Could not read note %s: %w",
"zh-CN": "读取笔记 %s 失败: %w"
},
"w_w_f0fd7d": {
"en": "%w: %w",
"zh-CN": "%w:%w"
},
"operation_failed": {
"en": "Operation failed: %s",
"zh-CN": "操作失败:%s"
},
"success": {
"en": "Success",
"zh-CN": "成功"
},
"invalid_locale": {
"en": "Language must be en or zh-CN",
"zh-CN": "语言必须为 en 或 zh-CN"
},
"metadata_unclosed": {
"en": "YAML front matter is not closed",
"zh-CN": "YAML 元数据未闭合"
},
"metadata_invalid": {
"en": "Invalid YAML front matter: %v",
"zh-CN": "YAML 元数据无效:%v"
},
"metadata_tags": {
"en": "YAML tags must be a string or a list of strings",
"zh-CN": "YAML tags 必须是字符串或字符串列表"
},
"metadata_tag_list": {
"en": "YAML tags must be a list of strings",
"zh-CN": "YAML tags 必须是字符串列表"
},
"import_missing_links": {
"en": "%s: %d local link targets are not included in the import. Original links retained",
"zh-CN": "%s:%d 个本地链接的目标不在导入内容中,已保留原链接"
},
"image_not_file": {
"en": "Not an image file",
"zh-CN": "不是图片文件"
},
"image_in_use": {
"en": "Still referenced by notes",
"zh-CN": "仍被笔记引用"
},
"version_conflict": {
"en": "Version conflict",
"zh-CN": "版本冲突"
},
"theme_default": {
"en": "Default",
"zh-CN": "默认"
},
"theme_white": {
"en": "Light",
"zh-CN": "白色"
},
"theme_black": {
"en": "Dark",
"zh-CN": "黑色"
},
"w_validate_the_settings_package_0f4f56": {
"en": "%w: validate the settings package first",
"zh-CN": "%w:请先校验设置包"
},
"w_a_validated_upload_cannot_ce0e63": {
"en": "%w: a validated upload cannot be modified",
"zh-CN": "%w:已校验的上传不可修改"
},
"w_upload_offset_does_not_95e21e": {
"en": "%w: upload offset does not match. Upload again",
"zh-CN": "%w:上传偏移不匹配,请重新上传"
},
"w_upload_size_exceeds_the_0bb329": {
"en": "%w: upload size exceeds the manifest",
"zh-CN": "%w:上传大小超出清单"
},
"w_upload_content_is_empty_7c0bb2": {
"en": "%w: upload content is empty",
"zh-CN": "%w:上传内容为空"
},
"w_upload_is_not_complete_8ef575": {
"en": "%w: upload is not complete",
"zh-CN": "%w:上传尚未完成"
},
"w_backup_type_does_not_8e5e8c": {
"en": "%w: backup type does not match the selected operation",
"zh-CN": "%w:备份类型与所选操作不符"
},
"w_vault_content_conflicts_with_6e3d24": {
"en": "%w: vault content conflicts with the application directory",
"zh-CN": "%w:笔记库内容与应用目录冲突"
},
"w_validate_the_full_instance_6d329d": {
"en": "%w: validate the full instance backup first",
"zh-CN": "%w:请先校验完整服务备份"
},
"image_source_http": {
"en": "Image source returned HTTP %d",
"zh-CN": "图片来源返回 HTTP %d"
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"rawmark/internal/i18n"
"strings" "strings"
"sync" "sync"
"unicode" "unicode"
@@ -75,7 +76,7 @@ func Open(path string, v *vault.Vault) (*Index, error) {
x := &Index{db: db, v: v, wake: make(chan struct{}, 1), known: map[string]doc{}} x := &Index{db: db, v: v, wake: make(chan struct{}, 1), known: map[string]doc{}}
if err := x.init(); err != nil { if err := x.init(); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("初始化搜索索引失败: %w", err) return nil, i18n.New("could_not_initialize_search_index_9155c3", err)
} }
return x, nil return x, nil
} }
+4 -5
View File
@@ -4,9 +4,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/url" "net/url"
"rawmark/internal/i18n"
"strings" "strings"
) )
@@ -28,7 +27,7 @@ func (s *Store) Preferences() (map[string]json.RawMessage, error) {
return nil, err return nil, err
} }
if !json.Valid([]byte(value)) { if !json.Valid([]byte(value)) {
return nil, fmt.Errorf("设置 %s 格式无效", key) return nil, i18n.New("invalid_format_for_setting_s_745c8c", key)
} }
result[key] = json.RawMessage(value) result[key] = json.RawMessage(value)
} }
@@ -47,7 +46,7 @@ func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
} }
for key, value := range values { for key, value := range values {
if (key != "app.settings" && key != "plugins.enabled" && !strings.HasPrefix(key, "plugin.data.")) || !json.Valid(value) { if (key != "app.settings" && key != "plugins.enabled" && !strings.HasPrefix(key, "plugin.data.")) || !json.Valid(value) {
return fmt.Errorf("设置包含无效设置项") return i18n.New("settings_contain_an_invalid_entry_90c647")
} }
if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, string(value)); err != nil { if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, string(value)); err != nil {
return err return err
@@ -68,7 +67,7 @@ func (s *Store) CheckBackup() error {
return err return err
} }
if result != "ok" { if result != "ok" {
return errors.New("备份数据库完整性检查失败") return i18n.New("backup_database_integrity_check_failed_5adb6a")
} }
for _, query := range []string{`SELECT key,value FROM settings LIMIT 0`, `SELECT token_hash,created_at,expires_at FROM sessions LIMIT 0`, `SELECT id,name,token_hash,scope,created_at,last_used_at FROM api_tokens LIMIT 0`} { for _, query := range []string{`SELECT key,value FROM settings LIMIT 0`, `SELECT token_hash,created_at,expires_at FROM sessions LIMIT 0`, `SELECT id,name,token_hash,scope,created_at,last_used_at FROM api_tokens LIMIT 0`} {
rows, err := s.db.Query(query) rows, err := s.db.Query(query)
+2 -2
View File
@@ -4,7 +4,7 @@ package store
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "rawmark/internal/i18n"
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -48,7 +48,7 @@ func Open(path string) (*Store, error) {
} }
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("初始化数据库失败: %w", err) return nil, i18n.New("could_not_initialize_database_w_208f9e", err)
} }
return &Store{db: db}, nil return &Store{db: db}, nil
} }
+11 -10
View File
@@ -1,6 +1,6 @@
package vault package vault
import "fmt" import "rawmark/internal/i18n"
type CleanupResult struct { type CleanupResult struct {
Deleted []TrashItem `json:"deleted"` Deleted []TrashItem `json:"deleted"`
@@ -9,6 +9,7 @@ type CleanupResult struct {
type CleanupSkip struct { type CleanupSkip struct {
Path string `json:"path"` Path string `json:"path"`
Reason string `json:"reason"` Reason string `json:"reason"`
Cause error `json:"-"`
} }
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。 // CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
@@ -31,7 +32,7 @@ func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
} }
metadata := Analyze(entry.Path, content) metadata := Analyze(entry.Path, content)
if len(metadata.Warnings) > 0 { if len(metadata.Warnings) > 0 {
return result, fmt.Errorf("%w:%s 的元数据异常,无法确认全库引用:%s", ErrInvalidPath, entry.Path, metadata.Warnings[0]) return result, i18n.New("w_invalid_metadata_in_s_478c93", ErrInvalidPath, entry.Path, metadata.Diagnostics[0])
} }
for _, ref := range metadata.Assets { for _, ref := range metadata.Assets {
references[ref.Target] = true references[ref.Target] = true
@@ -43,27 +44,27 @@ func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
continue continue
} }
seen[p] = true seen[p] = true
reason := "" var reason error
switch { switch {
case !IsImage(p): case !IsImage(p):
reason = "不是图片文件" reason = i18n.New("image_not_file")
case references[p]: case references[p]:
reason = "仍被笔记引用" reason = i18n.New("image_in_use")
default: default:
info, err := v.Stat(p) info, err := v.Stat(p)
if err != nil { if err != nil {
reason = err.Error() reason = err
} else if !info.Mode().IsRegular() { } else if !info.Mode().IsRegular() {
reason = "不是图片文件" reason = i18n.New("image_not_file")
} }
} }
if reason != "" { if reason != nil {
result.Skipped = append(result.Skipped, CleanupSkip{p, reason}) result.Skipped = append(result.Skipped, CleanupSkip{p, reason.Error(), reason})
continue continue
} }
item, err := v.deleteLocked(p) item, err := v.deleteLocked(p)
if err != nil { if err != nil {
result.Skipped = append(result.Skipped, CleanupSkip{p, err.Error()}) result.Skipped = append(result.Skipped, CleanupSkip{p, err.Error(), err})
continue continue
} }
result.Deleted = append(result.Deleted, item) result.Deleted = append(result.Deleted, item)
+5 -5
View File
@@ -3,13 +3,13 @@ package vault
import ( import (
"archive/zip" "archive/zip"
"context" "context"
"fmt"
"html" "html"
"io" "io"
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"sort" "sort"
"strings" "strings"
) )
@@ -18,7 +18,7 @@ import (
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。 // ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。
func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) { func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) {
if len(paths) == 0 { if len(paths) == 0 {
return nil, "", fmt.Errorf("%w:请选择导出内容", ErrInvalidPath) return nil, "", i18n.New("w_choose_content_to_export_170d5e", ErrInvalidPath)
} }
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
@@ -70,7 +70,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
} }
u, e := url.Parse(target) u, e := url.Parse(target)
if e != nil { if e != nil {
return nil, "", fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target) return nil, "", i18n.New("w_invalid_attachment_address_s_8b6ace", ErrInvalidPath, target)
} }
if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" { if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" {
continue continue
@@ -80,11 +80,11 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
continue continue
} }
if e = v.exportPath(rel); e != nil { if e = v.exportPath(rel); e != nil {
return nil, "", fmt.Errorf("附件 %s:%w", rel, e) return nil, "", i18n.New("attachment_s_w_7bc269", rel, e)
} }
info, e := v.Stat(rel) info, e := v.Stat(rel)
if e != nil { if e != nil {
return nil, "", fmt.Errorf("附件 %s:%w", rel, e) return nil, "", i18n.New("attachment_s_w_7bc269", rel, e)
} }
if !info.IsDir() { if !info.IsDir() {
files[rel] = false files[rel] = false
+3 -3
View File
@@ -3,20 +3,20 @@ package vault
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"html" "html"
"io" "io"
"io/fs" "io/fs"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"strings" "strings"
) )
// ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。 // ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。
func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error { func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error {
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) { if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
return fmt.Errorf("%w:请选择与原图片扩展名相同的文件", ErrInvalidPath) return i18n.New("w_choose_a_file_with_8410e9", ErrInvalidPath)
} }
p, err := v.abs(rel) p, err := v.abs(rel)
if err != nil { if err != nil {
@@ -30,7 +30,7 @@ func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) err
return ErrTooLarge return ErrTooLarge
} }
if len(data) == 0 { if len(data) == 0 {
return fmt.Errorf("%w:图片不能为空", ErrInvalidPath) return i18n.New("w_image_cannot_be_empty_a9a272", ErrInvalidPath)
} }
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
+28 -24
View File
@@ -6,12 +6,12 @@ import (
"compress/flate" "compress/flate"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"sort" "sort"
"strings" "strings"
"syscall" "syscall"
@@ -26,7 +26,7 @@ const (
importAssetLimit = 50 << 20 importAssetLimit = 50 << 20
) )
var ErrInvalidImport = errors.New("导入内容无效") var ErrInvalidImport = i18n.New("invalid_import_contents_ed556a")
// ImportFile 的读取范围是单个上传文件,ZIP 与普通 Markdown 共用这一输入边界。 // ImportFile 的读取范围是单个上传文件,ZIP 与普通 Markdown 共用这一输入边界。
type ImportFile struct { type ImportFile struct {
@@ -40,21 +40,22 @@ type ImportResult struct {
Notes int `json:"notes"` Notes int `json:"notes"`
Assets int `json:"assets"` Assets int `json:"assets"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
WarningMessages []*i18n.Error `json:"-"`
} }
// Import 在隐藏目录内整理完整内容,提交前统一检查冲突,绝不覆盖已有文件。 // Import 在隐藏目录内整理完整内容,提交前统一检查冲突,绝不覆盖已有文件。
func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) { func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) {
result = ImportResult{Paths: []string{}, Warnings: []string{}} result = ImportResult{Paths: []string{}, Warnings: []string{}}
if len(files) == 0 || len(files) > maxImportEntries { if len(files) == 0 || len(files) > maxImportEntries {
return result, fmt.Errorf("%w:请选择 Markdown 文件或一个 ZIP", ErrInvalidImport) return result, i18n.New("w_choose_markdown_files_or_bcaa52", ErrInvalidImport)
} }
zipped := len(files) == 1 && strings.EqualFold(path.Ext(files[0].Name), ".zip") zipped := len(files) == 1 && strings.EqualFold(path.Ext(files[0].Name), ".zip")
if zipped { if zipped {
if _, err := CleanPath(folder); err != nil || strings.Contains(folder, "/") || strings.HasSuffix(folder, AssetsExt) { if _, err := CleanPath(folder); err != nil || strings.Contains(folder, "/") || strings.HasSuffix(folder, AssetsExt) {
return result, fmt.Errorf("%w:导入文件夹名称无效", ErrInvalidImport) return result, i18n.New("w_invalid_import_folder_name_a10885", ErrInvalidImport)
} }
} else if folder != "" { } else if folder != "" {
return result, fmt.Errorf("%w:普通 Markdown 不使用外层文件夹", ErrInvalidImport) return result, i18n.New("w_plain_markdown_files_do_08b920", ErrInvalidImport)
} }
if dir != "" { if dir != "" {
if _, err := CleanPath(dir); err != nil { if _, err := CleanPath(dir); err != nil {
@@ -74,7 +75,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
} }
defer func() { defer func() {
if cleanup := os.RemoveAll(stage); cleanup != nil { if cleanup := os.RemoveAll(stage); cleanup != nil {
err = errors.Join(err, fmt.Errorf("清理导入临时目录失败:%w", cleanup)) err = errors.Join(err, i18n.New("could_not_clean_the_temporary_212214", cleanup))
} }
}() }()
staged := &Vault{root: stage} staged := &Vault{root: stage}
@@ -87,30 +88,30 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return err return err
} }
if _, err := CleanPath(name); err != nil || !utf8.ValidString(name) { if _, err := CleanPath(name); err != nil || !utf8.ValidString(name) {
return fmt.Errorf("%w:ZIP 含有非法路径", ErrInvalidImport) return i18n.New("w_zip_contains_an_invalid_7bcf46", ErrInvalidImport)
} }
if _, exists := entries[name]; exists { if _, exists := entries[name]; exists {
return fmt.Errorf("%w:存在重复路径", ErrInvalidImport) return i18n.New("w_duplicate_path_408694", ErrInvalidImport)
} }
entries[name] = struct{}{} entries[name] = struct{}{}
dest := filepath.Join(stage, filepath.FromSlash(name)) dest := filepath.Join(stage, filepath.FromSlash(name))
if isDir { if isDir {
err := os.MkdirAll(dest, 0o755) err := os.MkdirAll(dest, 0o755)
if errors.Is(err, fs.ErrExist) || errors.Is(err, syscall.ENOTDIR) { if errors.Is(err, fs.ErrExist) || errors.Is(err, syscall.ENOTDIR) {
return fmt.Errorf("%w:文件与目录路径冲突", ErrInvalidImport) return i18n.New("w_file_and_directory_paths_eb0b28", ErrInvalidImport)
} }
return err return err
} }
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
if errors.Is(err, syscall.ENOTDIR) || errors.Is(err, fs.ErrExist) { if errors.Is(err, syscall.ENOTDIR) || errors.Is(err, fs.ErrExist) {
return fmt.Errorf("%w:文件与目录路径冲突", ErrInvalidImport) return i18n.New("w_file_and_directory_paths_eb0b28", ErrInvalidImport)
} }
return err return err
} }
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrExist) || errors.Is(err, syscall.ENOTDIR) { if errors.Is(err, fs.ErrExist) || errors.Is(err, syscall.ENOTDIR) {
return fmt.Errorf("%w:文件与目录路径冲突", ErrInvalidImport) return i18n.New("w_file_and_directory_paths_eb0b28", ErrInvalidImport)
} }
return err return err
} }
@@ -131,7 +132,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
if zipped { if zipped {
z, err := zip.NewReader(files[0].Reader, files[0].Size) z, err := zip.NewReader(files[0].Reader, files[0].Size)
if err != nil { if err != nil {
return result, fmt.Errorf("%w:无法读取 ZIP:%w", ErrInvalidImport, err) return result, i18n.New("w_could_not_read_zip_d4a777", ErrInvalidImport, err)
} }
if len(z.File) > maxImportEntries { if len(z.File) > maxImportEntries {
return result, ErrTooLarge return result, ErrTooLarge
@@ -141,19 +142,19 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
continue continue
} // 本程序笔记导出的缺失附件报告,不作为笔记导入。 } // 本程序笔记导出的缺失附件报告,不作为笔记导入。
if !f.Mode().IsRegular() && !f.Mode().IsDir() { if !f.Mode().IsRegular() && !f.Mode().IsDir() {
return result, fmt.Errorf("%w:ZIP 不允许符号链接或特殊文件", ErrInvalidImport) return result, i18n.New("w_zip_cannot_contain_symlinks_5f596f", ErrInvalidImport)
} }
name := strings.TrimSuffix(f.Name, "/") name := strings.TrimSuffix(f.Name, "/")
r, err := f.Open() r, err := f.Open()
if err != nil { if err != nil {
return result, fmt.Errorf("%w:无法读取 ZIP 条目:%w", ErrInvalidImport, err) return result, i18n.New("w_could_not_read_zip_8158b9", ErrInvalidImport, err)
} }
err = write(name, f.FileInfo().IsDir(), r) err = write(name, f.FileInfo().IsDir(), r)
err = errors.Join(err, r.Close()) err = errors.Join(err, r.Close())
if err != nil { if err != nil {
var corrupt flate.CorruptInputError var corrupt flate.CorruptInputError
if errors.Is(err, zip.ErrChecksum) || errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &corrupt) { if errors.Is(err, zip.ErrChecksum) || errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &corrupt) {
err = fmt.Errorf("%w:ZIP 内容损坏:%w", ErrInvalidImport, err) err = i18n.New("w_zip_content_is_damaged_6323c7", ErrInvalidImport, err)
} }
return result, err return result, err
} }
@@ -161,7 +162,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
} else { } else {
for _, f := range files { for _, f := range files {
if !IsNote(f.Name) || strings.Contains(f.Name, "/") { if !IsNote(f.Name) || strings.Contains(f.Name, "/") {
return result, fmt.Errorf("%w:只接受 .md 文件或单个 ZIP", ErrInvalidImport) return result, i18n.New("w_only_md_files_or_6f1a4f", ErrInvalidImport)
} }
if err := write(f.Name, false, io.NewSectionReader(f.Reader, 0, f.Size)); err != nil { if err := write(f.Name, false, io.NewSectionReader(f.Reader, 0, f.Size)); err != nil {
return result, err return result, err
@@ -169,7 +170,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
} }
} }
if len(notes) == 0 { if len(notes) == 0 {
return result, fmt.Errorf("%w:没有可导入的 Markdown 笔记", ErrInvalidImport) return result, i18n.New("w_no_importable_markdown_notes_3ad0b8", ErrInvalidImport)
} }
sort.Strings(notes) sort.Strings(notes)
migrated := map[string]bool{} migrated := map[string]bool{}
@@ -179,7 +180,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
} }
for _, seg := range strings.Split(path.Dir(note), "/") { for _, seg := range strings.Split(path.Dir(note), "/") {
if strings.HasSuffix(seg, AssetsExt) { if strings.HasSuffix(seg, AssetsExt) {
return result, fmt.Errorf("%w:笔记不能放在 .assets 附件目录内", ErrInvalidImport) return result, i18n.New("w_notes_cannot_be_inside_744972", ErrInvalidImport)
} }
} }
content, _, err := staged.Read(note) content, _, err := staged.Read(note)
@@ -187,14 +188,17 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err return result, err
} }
if !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { if !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 {
return result, fmt.Errorf("%w:Markdown 必须是 UTF-8 文本", ErrInvalidImport) return result, i18n.New("w_markdown_must_be_utf_ce0d80", ErrInvalidImport)
} }
content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf}) content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf})
updated, warnings, err := staged.importLinks(ctx, note, content, &total, migrated) updated, warnings, err := staged.importLinks(ctx, note, content, &total, migrated)
if err != nil { if err != nil {
return result, err return result, err
} }
result.Warnings = append(result.Warnings, warnings...) result.WarningMessages = append(result.WarningMessages, warnings...)
for _, warning := range warnings {
result.Warnings = append(result.Warnings, warning.Error())
}
if len(updated) > importNoteLimit || total+int64(len(updated)-len(content)) > maxImportExpanded { if len(updated) > importNoteLimit || total+int64(len(updated)-len(content)) > maxImportExpanded {
return result, ErrTooLarge return result, ErrTooLarge
} }
@@ -213,7 +217,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
} }
metadata := Analyze(note, body) metadata := Analyze(note, body)
if len(metadata.Warnings) > 0 { if len(metadata.Warnings) > 0 {
return result, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidImport, note) return result, i18n.New("w_invalid_metadata_in_s_cc1d10", ErrInvalidImport, note)
} }
for _, ref := range metadata.Assets { for _, ref := range metadata.Assets {
used[ref.Target] = true used[ref.Target] = true
@@ -275,7 +279,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err return result, err
} }
if !info.IsDir() { if !info.IsDir() {
return result, fmt.Errorf("%w:目标不是文件夹", ErrInvalidImport) return result, i18n.New("w_destination_is_not_a_f9de1c", ErrInvalidImport)
} }
for _, target := range targets { for _, target := range targets {
dest, err := v.abs(target) dest, err := v.abs(target)
@@ -283,7 +287,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err return result, err
} }
if _, err := os.Lstat(dest); err == nil { if _, err := os.Lstat(dest); err == nil {
return result, fmt.Errorf("%w:%s,请修改名称", ErrExists, target) return result, i18n.New("w_s_choose_a_different_ac4d15", ErrExists, target)
} else if !errors.Is(err, fs.ErrNotExist) { } else if !errors.Is(err, fs.ErrNotExist) {
return result, err return result, err
} }
@@ -301,7 +305,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
if err = os.Rename(filepath.Join(stage, name), filepath.Join(parent, name)); err != nil { if err = os.Rename(filepath.Join(stage, name), filepath.Join(parent, name)); err != nil {
for _, prev := range published { for _, prev := range published {
if rollback := os.Rename(filepath.Join(parent, prev), filepath.Join(stage, prev)); rollback != nil { if rollback := os.Rename(filepath.Join(parent, prev), filepath.Join(stage, prev)); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚导入失败:%w", rollback)) err = errors.Join(err, i18n.New("import_rollback_failed_w_50748f", rollback))
} }
} }
break break
+4 -4
View File
@@ -8,9 +8,9 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
) )
@@ -228,7 +228,7 @@ func importLinkSpans(content []byte) []importLink {
return spans return spans
} }
func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64, migrated map[string]bool) ([]byte, []string, error) { func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64, migrated map[string]bool) ([]byte, []*i18n.Error, error) {
missing := map[string]bool{} missing := map[string]bool{}
replacements := map[string]string{} replacements := map[string]string{}
spans := importLinkSpans(content) spans := importLinkSpans(content)
@@ -312,9 +312,9 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
last = span.end last = span.end
} }
updated := append(out, content[last:]...) updated := append(out, content[last:]...)
warnings := []string{} warnings := []*i18n.Error{}
if len(missing) > 0 { if len(missing) > 0 {
warnings = append(warnings, note+":"+strconv.Itoa(len(missing))+" 个本地链接的目标不在导入内容中,已保留原链接") warnings = append(warnings, i18n.New("import_missing_links", note, len(missing)))
} }
return updated, warnings, nil return updated, warnings, nil
} }
+12 -6
View File
@@ -2,10 +2,10 @@ package vault
import ( import (
"bytes" "bytes"
"fmt"
"html" "html"
"net/url" "net/url"
"path" "path"
"rawmark/internal/i18n"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -27,6 +27,7 @@ type Metadata struct {
Links []Reference `json:"links"` Links []Reference `json:"links"`
Assets []Reference `json:"assets"` Assets []Reference `json:"assets"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
Diagnostics []*i18n.Error `json:"-"`
} }
var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`) var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`)
@@ -48,6 +49,11 @@ func LocalTarget(note, raw string) (string, *url.URL, bool) {
// Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。 // Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。
func Analyze(note string, content []byte) Metadata { func Analyze(note string, content []byte) Metadata {
m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}} m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
addWarning := func(key string, args ...any) {
diagnostic := i18n.New(key, args...)
m.Diagnostics = append(m.Diagnostics, diagnostic)
m.Warnings = append(m.Warnings, diagnostic.Error())
}
tags := map[string]bool{} tags := map[string]bool{}
addTag := func(s string) { addTag := func(s string) {
s = strings.TrimPrefix(strings.TrimSpace(s), "#") s = strings.TrimPrefix(strings.TrimSpace(s), "#")
@@ -68,7 +74,7 @@ func Analyze(note string, content []byte) Metadata {
offset += len(lines[i]) offset += len(lines[i])
} }
if end < 0 { if end < 0 {
m.Warnings = append(m.Warnings, "YAML 元数据未闭合") addWarning("metadata_unclosed")
end = len(source) end = len(source)
} else { } else {
var front struct { var front struct {
@@ -76,7 +82,7 @@ func Analyze(note string, content []byte) Metadata {
Title string `yaml:"title"` Title string `yaml:"title"`
} }
if err := yaml.Unmarshal(source[len(lines[0]):bodyEnd], &front); err != nil { if err := yaml.Unmarshal(source[len(lines[0]):bodyEnd], &front); err != nil {
m.Warnings = append(m.Warnings, fmt.Sprintf("YAML 元数据无效:%v", err)) addWarning("metadata_invalid", err)
} else { } else {
if front.Title != "" { if front.Title != "" {
m.Title = front.Title m.Title = front.Title
@@ -89,18 +95,18 @@ func Analyze(note string, content []byte) Metadata {
addTag(tag) addTag(tag)
} }
} else { } else {
m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表") addWarning("metadata_tags")
} }
case yaml.SequenceNode: case yaml.SequenceNode:
for _, tag := range front.Tags.Content { for _, tag := range front.Tags.Content {
if tag.Kind != yaml.ScalarNode || tag.Tag != "!!str" { if tag.Kind != yaml.ScalarNode || tag.Tag != "!!str" {
m.Warnings = append(m.Warnings, "YAML tags 必须是字符串列表") addWarning("metadata_tag_list")
break break
} }
addTag(tag.Value) addTag(tag.Value)
} }
default: default:
m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表") addWarning("metadata_tags")
} }
} }
} }
+3 -3
View File
@@ -1,10 +1,10 @@
package vault package vault
import ( import (
"fmt"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"strings" "strings"
) )
@@ -25,7 +25,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ
} }
metadata := Analyze(from, content) metadata := Analyze(from, content)
if len(metadata.Warnings) != 0 { if len(metadata.Warnings) != 0 {
return nil, nil, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidPath, from) return nil, nil, i18n.New("w_invalid_metadata_in_s_cc1d10", ErrInvalidPath, from)
} }
var images []Reference var images []Reference
for _, ref := range metadata.Assets { for _, ref := range metadata.Assets {
@@ -51,7 +51,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ
} }
m := Analyze(entry.Path, body) m := Analyze(entry.Path, body)
if len(m.Warnings) != 0 { if len(m.Warnings) != 0 {
return nil, nil, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidPath, entry.Path) return nil, nil, i18n.New("w_invalid_metadata_in_s_cc1d10", ErrInvalidPath, entry.Path)
} }
for _, ref := range m.Assets { for _, ref := range m.Assets {
used[ref.Target] = true used[ref.Target] = true
+4 -3
View File
@@ -3,9 +3,9 @@ package vault
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"time" "time"
) )
@@ -39,6 +39,7 @@ func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
type TrashFailure struct { type TrashFailure struct {
ID string `json:"id"` ID string `json:"id"`
Error string `json:"error"` Error string `json:"error"`
Cause error `json:"-"`
} }
type TrashPurgeResult struct { type TrashPurgeResult struct {
@@ -53,7 +54,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
defer v.mu.Unlock() defer v.mu.Unlock()
result := TrashPurgeResult{Deleted: []string{}, Failed: []TrashFailure{}} result := TrashPurgeResult{Deleted: []string{}, Failed: []TrashFailure{}}
if days < 0 { if days < 0 {
return result, fmt.Errorf("保留天数不能为负数") return result, i18n.New("retention_days_cannot_be_negative_f37c9b")
} }
dirs, err := os.ReadDir(v.trash) dirs, err := os.ReadDir(v.trash)
if err != nil { if err != nil {
@@ -72,7 +73,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
err = v.purgeTrashItemLocked(metadata) err = v.purgeTrashItemLocked(metadata)
} }
if err != nil { if err != nil {
result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()}) result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error(), Cause: err})
} else { } else {
result.Deleted = append(result.Deleted, id) result.Deleted = append(result.Deleted, id)
} }
+5 -5
View File
@@ -3,10 +3,10 @@ package vault
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"regexp" "regexp"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -18,7 +18,7 @@ type TreeIcon struct {
Value string `json:"value"` Value string `json:"value"`
} }
var ErrInvalidIcon = errors.New("非法图标") var ErrInvalidIcon = i18n.New("invalid_icon_59d610")
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 " 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 "
@@ -46,14 +46,14 @@ func (v *Vault) readTreeIcons() (map[string]TreeIcon, error) {
} }
var icons map[string]TreeIcon var icons map[string]TreeIcon
if err := json.Unmarshal(data, &icons); err != nil { if err := json.Unmarshal(data, &icons); err != nil {
return nil, fmt.Errorf("文件树图标元数据损坏: %w", err) return nil, i18n.New("file_tree_icon_metadata_is_49b5aa", err)
} }
if icons == nil { if icons == nil {
return nil, errors.New("文件树图标元数据必须是对象") return nil, i18n.New("file_tree_icon_metadata_must_54f104")
} }
for p, icon := range icons { for p, icon := range icons {
if _, err := CleanPath(p); err != nil || !icon.valid() { if _, err := CleanPath(p); err != nil || !icon.valid() {
return nil, errors.New("文件树图标元数据含非法路径或图标") return nil, i18n.New("file_tree_icon_metadata_contains_eb66b6")
} }
} }
return icons, nil return icons, nil
+22 -21
View File
@@ -15,6 +15,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -23,16 +24,16 @@ import (
) )
var ( var (
ErrInvalidPath = errors.New("非法路径") ErrInvalidPath = i18n.New("invalid_path_ec93bf")
ErrNotFound = errors.New("文件不存在") ErrNotFound = i18n.New("file_not_found_3703e4")
ErrExists = errors.New("目标已存在") ErrExists = i18n.New("destination_already_exists_9511c3")
ErrTooLarge = errors.New("文件过大") ErrTooLarge = i18n.New("file_is_too_large_5bfcab")
) )
// ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空) // ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空)
type ConflictError struct{ Rev string } type ConflictError struct{ Rev string }
func (e *ConflictError) Error() string { return "版本冲突" } func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() }
const ( const (
NoteExt = ".md" NoteExt = ".md"
@@ -438,14 +439,14 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
for _, image := range images { for _, image := range images {
if image.removed { if image.removed {
if rollback := atomicWrite(filepath.Join(v.root, filepath.FromSlash(image.source)), image.data); rollback != nil { if rollback := atomicWrite(filepath.Join(v.root, filepath.FromSlash(image.source)), image.data); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚图片原件失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_the_d7c4fc", rollback))
} }
} }
} }
for _, image := range images { for _, image := range images {
if image.created { if image.created {
if rollback := os.Remove(filepath.Join(v.root, filepath.FromSlash(image.target))); rollback != nil { if rollback := os.Remove(filepath.Join(v.root, filepath.FromSlash(image.target))); rollback != nil {
err = errors.Join(err, fmt.Errorf("撤回图片副本失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_remove_the_copied_e96efd", rollback))
} }
} }
} }
@@ -475,16 +476,16 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
} }
if assetsMoved { if assetsMoved {
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil { if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚旧附件目录失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_the_f89084", rollback))
} }
} }
for _, change := range links[:writtenLinks] { for _, change := range links[:writtenLinks] {
if rollback := atomicWrite(change.path, change.before); rollback != nil { if rollback := atomicWrite(change.path, change.before); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_note_8554a3", rollback))
} }
} }
if rollback := os.Rename(dst, src); rollback != nil { if rollback := os.Rename(dst, src); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_the_36ec22", rollback))
} }
}() }()
for _, change := range links { for _, change := range links {
@@ -498,7 +499,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
} }
if srcAssets != "" { if srcAssets != "" {
if err := os.Rename(srcAssets, dstAssets); err != nil { if err := os.Rename(srcAssets, dstAssets); err != nil {
return false, fmt.Errorf("移动图片目录失败: %w", err) return false, i18n.New("could_not_move_image_folder_c39928", err)
} }
assetsMoved = true assetsMoved = true
} }
@@ -580,13 +581,13 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
rollbackFailed := false rollbackFailed := false
if assetsMoved { if assetsMoved {
if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil { if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚回收站图片失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_trashed_b37d4f", rollback))
rollbackFailed = true rollbackFailed = true
} }
} }
if noteMoved { if noteMoved {
if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil { if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_trashed_3758a4", rollback))
rollbackFailed = true rollbackFailed = true
} }
} }
@@ -607,7 +608,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
noteMoved = true noteMoved = true
if item.Assets != "" { if item.Assets != "" {
if err = os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil { if err = os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil {
return item, fmt.Errorf("移动图片目录到回收站失败: %w", err) return item, i18n.New("could_not_move_image_folder_7c4620", err)
} }
} }
assetsMoved = item.Assets != "" assetsMoved = item.Assets != ""
@@ -635,14 +636,14 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
} }
var metadata trashMetadata var metadata trashMetadata
if err := json.Unmarshal(data, &metadata); err != nil { if err := json.Unmarshal(data, &metadata); err != nil {
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err) return trashMetadata{}, i18n.New("trash_item_s_has_damaged_ba382a", id, err)
} }
if _, err := CleanPath(metadata.Path); err != nil || metadata.ID != id || metadata.DeletedAt <= 0 || (metadata.Assets != "" && metadata.Assets != filepath.Base(AssetsDir(metadata.Path))) { 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) return trashMetadata{}, i18n.New("trash_item_s_has_invalid_006492", id)
} }
for p, icon := range metadata.Icons { 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)+"/"))) { 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 trashMetadata{}, i18n.New("trash_item_s_has_damaged_856237", id)
} }
} }
return metadata, nil return metadata, nil
@@ -722,16 +723,16 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
} }
if assetsMoved { if assetsMoved {
if rollback := os.Rename(AssetsDir(dst), filepath.Join(dir, item.Assets)); rollback != nil { if rollback := os.Rename(AssetsDir(dst), filepath.Join(dir, item.Assets)); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚恢复图片失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_restored_952073", rollback))
} }
} }
if rollback := os.Rename(dst, filepath.Join(dir, filepath.Base(dst))); rollback != nil { if rollback := os.Rename(dst, filepath.Join(dir, filepath.Base(dst))); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚恢复笔记失败: %w", rollback)) err = errors.Join(err, i18n.New("could_not_roll_back_restored_16b803", rollback))
} }
}() }()
if item.Assets != "" { if item.Assets != "" {
if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil { if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil {
return item, fmt.Errorf("恢复图片目录失败: %w", err) return item, i18n.New("could_not_restore_image_folder_f6e087", err)
} }
assetsMoved = true assetsMoved = true
} }
@@ -774,7 +775,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。 // SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) { func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
if !IsImage(origName) { if !IsImage(origName) {
return "", fmt.Errorf("%w:请选择图片文件", ErrInvalidPath) return "", i18n.New("w_choose_an_image_file_544069", ErrInvalidPath)
} }
if folder != "" { if folder != "" {
info, err := v.Stat(folder) info, err := v.Stat(folder)
+2 -2
View File
@@ -5,11 +5,11 @@ package watch
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/i18n"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -148,7 +148,7 @@ func (w *Watcher) scan() (map[string]fileState, error) {
continue // 扫描期间被删除,下一轮会处理 continue // 扫描期间被删除,下一轮会处理
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("读取笔记 %s 失败: %w", e.Path, err) return nil, i18n.New("could_not_read_note_s_708ab5", e.Path, err)
} }
st.rev = vault.Rev(data) st.rev = vault.Rev(data)
} }
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" data-rm-fonts="loading"> <html lang="en" data-rm-fonts="loading">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+1
View File
@@ -30,6 +30,7 @@
"pinia": "3.0.4", "pinia": "3.0.4",
"turndown": "7.2.4", "turndown": "7.2.4",
"vue": "3.5.44", "vue": "3.5.44",
"vue-i18n": "11.4.16",
"vue-router": "5.1.0" "vue-router": "5.1.0"
}, },
"devDependencies": { "devDependencies": {
+56
View File
@@ -227,6 +227,9 @@ importers:
vue: vue:
specifier: 3.5.44 specifier: 3.5.44
version: 3.5.44([email protected]) version: 3.5.44([email protected])
vue-i18n:
specifier: 11.4.16
version: 11.4.16([email protected]([email protected]))
vue-router: vue-router:
specifier: 5.1.0 specifier: 5.1.0
version: 5.1.0(@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected])) version: 5.1.0(@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))
@@ -590,6 +593,22 @@ packages:
'@iconify/[email protected]': '@iconify/[email protected]':
resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==}
'@intlify/[email protected]':
resolution: {integrity: sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==}
engines: {node: '>= 22'}
'@intlify/[email protected]':
resolution: {integrity: sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==}
engines: {node: '>= 22'}
'@intlify/[email protected]':
resolution: {integrity: sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==}
engines: {node: '>= 22'}
'@intlify/[email protected]':
resolution: {integrity: sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==}
engines: {node: '>= 22'}
'@isaacs/[email protected]': '@isaacs/[email protected]':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1398,6 +1417,9 @@ packages:
'@vue/[email protected]': '@vue/[email protected]':
resolution: {integrity: sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==} resolution: {integrity: sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==}
'@vue/[email protected]':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/[email protected]': '@vue/[email protected]':
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
@@ -3561,6 +3583,12 @@ packages:
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
[email protected]:
resolution: {integrity: sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==}
engines: {node: '>= 22'}
peerDependencies:
vue: ^3.0.0
[email protected]: [email protected]:
resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==}
peerDependencies: peerDependencies:
@@ -4021,6 +4049,24 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
import-meta-resolve: 4.2.0 import-meta-resolve: 4.2.0
'@intlify/[email protected]':
dependencies:
'@intlify/devtools-types': 11.4.16
'@intlify/message-compiler': 11.4.16
'@intlify/shared': 11.4.16
'@intlify/[email protected]':
dependencies:
'@intlify/core-base': 11.4.16
'@intlify/shared': 11.4.16
'@intlify/[email protected]':
dependencies:
'@intlify/shared': 11.4.16
source-map-js: 1.2.2
'@intlify/[email protected]': {}
'@isaacs/[email protected]': '@isaacs/[email protected]':
dependencies: dependencies:
string-width: 5.1.2 string-width: 5.1.2
@@ -4791,6 +4837,8 @@ snapshots:
'@vue/compiler-dom': 3.5.44 '@vue/compiler-dom': 3.5.44
'@vue/shared': 3.5.44 '@vue/shared': 3.5.44
'@vue/[email protected]': {}
'@vue/[email protected]': '@vue/[email protected]':
dependencies: dependencies:
'@vue/devtools-kit': 7.7.10 '@vue/devtools-kit': 7.7.10
@@ -7143,6 +7191,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
[email protected]([email protected]([email protected])):
dependencies:
'@intlify/core-base': 11.4.16
'@intlify/devtools-types': 11.4.16
'@intlify/shared': 11.4.16
'@vue/devtools-api': 6.6.4
vue: 3.5.44([email protected])
[email protected](@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected])): [email protected](@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected])):
dependencies: dependencies:
'@babel/generator': 8.0.6 '@babel/generator': 8.0.6
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "/", "id": "/",
"name": "RawMark", "name": "RawMark",
"short_name": "RawMark", "short_name": "RawMark",
"lang": "zh-CN", "lang": "en",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
+5 -2
View File
@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import Toasts from '@@/components/Toasts/index.vue' import Toasts from '@@/components/Toasts/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import en from 'element-plus/es/locale/lang/en'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { navigationError, router } from '@/router' import { navigationError, router } from '@/router'
@@ -12,10 +15,10 @@ function retry() {
</script> </script>
<template> <template>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="locale === 'zh-CN' ? zhCn : en">
<div v-if="navigationError" class="rm-splash" role="alert"> <div v-if="navigationError" class="rm-splash" role="alert">
<p>{{ navigationError.message }}</p><el-button @click="retry"> <p>{{ navigationError.message }}</p><el-button @click="retry">
重试 {{ translate('common.retry_b8784c') }}
</el-button> </el-button>
</div> </div>
<router-view v-else v-slot="{ Component, route }"> <router-view v-else v-slot="{ Component, route }">
@@ -81,8 +81,8 @@ it('字体失败显示可刷新提示,之后 ready 完成和其他字体加载
await Promise.resolve() await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error') expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1) expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(document.querySelector('[role="alert"]')!.textContent).toContain('字体加载失败') expect(document.querySelector('[role="alert"]')!.textContent).toContain('Fonts could not be loaded')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('刷新页面') expect(document.querySelector('[role="alert"] button')!.textContent).toBe('Refresh page')
fonts.load() fonts.load()
expect(document.documentElement.dataset.rmFonts).toBe('error') expect(document.documentElement.dataset.rmFonts).toBe('error')
}) })
+3 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。 // 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。
export function watchFontLoading() { export function watchFontLoading() {
const root = document.documentElement const root = document.documentElement
@@ -11,9 +12,9 @@ export function watchFontLoading() {
panel.className = 'rm-font-error' panel.className = 'rm-font-error'
panel.setAttribute('role', 'alert') panel.setAttribute('role', 'alert')
const message = document.createElement('p') const message = document.createElement('p')
message.textContent = '字体加载失败,请检查网络后刷新页面。' message.textContent = translate('common.fonts_could_not_be_loaded_42a574')
const retry = document.createElement('button') const retry = document.createElement('button')
retry.textContent = '刷新页面' retry.textContent = translate('common.refresh_page_9366e1')
retry.addEventListener('click', () => location.reload()) retry.addEventListener('click', () => location.reload())
panel.append(message, retry) panel.append(message, retry)
document.body.append(panel) document.body.append(panel)
+1 -1
View File
@@ -5,7 +5,7 @@ import { applyFontCatalog, parseFontCatalog } from './fonts'
describe('导入字体资源边界', () => { describe('导入字体资源边界', () => {
it('拒绝可插入CSS的标识和不支持的格式', () => { it('拒绝可插入CSS的标识和不支持的格式', () => {
for (const value of [null, {}, [{}], [{ id: 'custom-\');}body{color:red}', name: 'bad', format: 'woff2' }], [{ id: `custom-${'a'.repeat(64)}`, name: 'bad', format: 'exe' }]]) { for (const value of [null, {}, [{}], [{ id: 'custom-\');}body{color:red}', name: 'bad', format: 'woff2' }], [{ id: `custom-${'a'.repeat(64)}`, name: 'bad', format: 'exe' }]]) {
expect(() => parseFontCatalog(value)).toThrow('字体列表格式无效') expect(() => parseFontCatalog(value)).toThrow('Invalid font catalog')
} }
}) })
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => { it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import type { CustomFont } from '@@/apis/fonts/type' import type { CustomFont } from '@@/apis/fonts/type'
import { getFontsApi } from '@@/apis/fonts' import { getFontsApi } from '@@/apis/fonts'
import { t as translate } from '@@/i18n/runtime'
import { fontResourceRoot, prepareResources } from '@@/utils/resource-tickets' import { fontResourceRoot, prepareResources } from '@@/utils/resource-tickets'
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
@@ -18,7 +19,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
} }
export function parseFontCatalog(value: unknown): CustomFont[] { export function parseFontCatalog(value: unknown): CustomFont[] {
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error('字体列表格式无效') if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error(translate('common.invalid_font_catalog_b577df'))
return value return value
} }
+13 -4
View File
@@ -1,15 +1,24 @@
import { t as translate } from '@@/i18n/runtime'
// 编辑器排版设置;账户、快捷键等设置不进入此模块。 // 编辑器排版设置;账户、快捷键等设置不进入此模块。
const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' } const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' }
export const TEXT_FONTS = { export const TEXT_FONTS = {
maple, maple,
sans: { label: 'Manrope · 清晰无衬线', css: '\'Manrope\', \'PingFang SC\', \'Microsoft YaHei\', sans-serif' }, sans: { get label() {
serif: { label: 'Noto Serif SC · 思源宋体', css: '\'Noto Serif SC\', \'Source Han Serif SC\', serif' }, return translate('common.manrope_sans_serif_a8923c')
system: { label: '系统字体', css: 'system-ui, sans-serif' }, }, css: '\'Manrope\', \'PingFang SC\', \'Microsoft YaHei\', sans-serif' },
serif: { get label() {
return translate('common.noto_serif_sc_serif_18133d')
}, css: '\'Noto Serif SC\', \'Source Han Serif SC\', serif' },
system: { get label() {
return translate('common.system_font_96a2a0')
}, css: 'system-ui, sans-serif' },
} }
export const CODE_FONTS = { export const CODE_FONTS = {
maple, maple,
jetbrains: { label: 'JetBrains Mono', css: '\'JetBrains Mono\', ui-monospace, monospace' }, jetbrains: { label: 'JetBrains Mono', css: '\'JetBrains Mono\', ui-monospace, monospace' },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' }, system: { get label() {
return translate('common.system_monospace_e32ca0')
}, css: 'ui-monospace, monospace' },
} }
export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 } export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
export const FONT_PRESETS: Record<string, { label: string, css: string }> = { ...TEXT_FONTS, jetbrains: CODE_FONTS.jetbrains } export const FONT_PRESETS: Record<string, { label: string, css: string }> = { ...TEXT_FONTS, jetbrains: CODE_FONTS.jetbrains }
+10 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { online, waitingWorker } from '@@/utils/pwa' import { online, waitingWorker } from '@@/utils/pwa'
import { ref } from 'vue' import { ref } from 'vue'
@@ -14,7 +16,7 @@ async function update() {
busy.value = true busy.value = true
try { try {
if (!await props.beforeReload()) { if (!await props.beforeReload()) {
toast('请先完成保存、图片上传或文件操作,再更新应用') toast(translate('common.finish_saving_uploading_images_or_b9e96e'))
return return
} }
if (!activated.value && worker) { if (!activated.value && worker) {
@@ -25,11 +27,11 @@ async function update() {
clearTimeout(timer) clearTimeout(timer)
worker.removeEventListener('statechange', finish) worker.removeEventListener('statechange', finish)
if (worker.state === 'activated') resolve() if (worker.state === 'activated') resolve()
else reject(new Error('新版本激活失败')) else reject(new Error(translate('common.could_not_activate_the_new_c0fd5b')))
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
worker.removeEventListener('statechange', finish) worker.removeEventListener('statechange', finish)
reject(new Error('等待新版本超时')) reject(new Error(translate('common.timed_out_waiting_for_the_57775d')))
}, 15000) }, 15000)
worker.addEventListener('statechange', finish) worker.addEventListener('statechange', finish)
worker.postMessage('ACTIVATE') worker.postMessage('ACTIVATE')
@@ -39,9 +41,9 @@ async function update() {
} }
// 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。 // 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。
if (await props.beforeReload()) location.reload() if (await props.beforeReload()) location.reload()
else toast('仍有未完成的保存或操作,请完成后再次点击刷新') else toast(translate('common.some_changes_or_operations_are_a5fda9'))
} catch { } catch {
toast('暂时无法更新,请稍后重试') toast(translate('common.cannot_update_right_now_please_c0de6a'))
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -50,11 +52,11 @@ async function update() {
<template> <template>
<div v-if="!online || waitingWorker || activated" class="rm-app-status" role="status"> <div v-if="!online || waitingWorker || activated" class="rm-app-status" role="status">
<span v-if="!online">离线,无法加载或保存笔记</span> <span v-if="!online">{{ translate('common.offline_notes_cannot_be_loaded_8ecb92') }}</span>
<template v-else-if="waitingWorker || activated"> <template v-else-if="waitingWorker || activated">
<span>有新版本</span> <span>{{ translate('common.update_available_daedea') }}</span>
<el-button class="rm-btn" :disabled="busy" @click="update"> <el-button class="rm-btn" :disabled="busy" @click="update">
{{ busy ? '正在保存…' : '保存并刷新' }} {{ busy ? translate('common.saving_6bdb44') : translate('common.save_and_refresh_d080ec') }}
</el-button> </el-button>
</template> </template>
</div> </div>
+13 -11
View File
@@ -3,6 +3,8 @@ import type { Settings } from '@/pinia/stores/settings'
import { deleteFontApi, importFontApi } from '@@/apis/fonts' import { deleteFontApi, importFontApi } from '@@/apis/fonts'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -48,7 +50,7 @@ async function save(patch: Partial<Settings>) {
try { try {
await loadSettings() await loadSettings()
} catch (reload) { } catch (reload) {
error.value += `;重新读取设置失败:${String(reload)}` error.value += translate('common.could_not_reload_settings_86605a', { p0: String(reload) })
} }
return false return false
} }
@@ -63,21 +65,21 @@ async function importFont(event: Event) {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
if (file.size > 30 * 1024 * 1024) throw new Error('字体文件超过 30 MB') if (file.size > 30 * 1024 * 1024) throw new Error(translate('common.font_files_must_not_exceed_cc001c'))
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('请选择 TTF、OTF、WOFF 或 WOFF2 字体') if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error(translate('common.choose_a_ttf_otf_woff_eb6ab7'))
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。 // 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
const data = await file.arrayBuffer() const data = await file.arrayBuffer()
try { try {
await new FontFace('rawmark-import-check', data).load() await new FontFace('rawmark-import-check', data).load()
} catch (cause) { } catch (cause) {
throw new Error('无法解析字体文件,请选择有效的字体。', { cause }) throw new Error(translate('common.could_not_read_this_font_ecc62f'), { cause })
} }
user.assertSession(version) user.assertSession(version)
const font = (await importFontApi(file)).data const font = (await importFontApi(file)).data
await loadFonts() await loadFonts()
user.assertSession(version) user.assertSession(version)
if (!await save({ [props.setting]: font.id })) return if (!await save({ [props.setting]: font.id })) return
toast('字体已导入') toast(translate('common.font_imported_3dc232'))
} catch (cause) { } catch (cause) {
if (isCancel(cause)) return if (isCancel(cause)) return
error.value = cause instanceof Error ? cause.message : String(cause) error.value = cause instanceof Error ? cause.message : String(cause)
@@ -100,7 +102,7 @@ async function remove(id: string) {
await deleteFontApi(id) await deleteFontApi(id)
await loadSettings() await loadSettings()
} }
toast('字体已删除,使用它的位置已恢复 Maple') toast(translate('common.font_deleted_its_uses_have_b83e92'))
} catch (cause) { } catch (cause) {
if (isCancel(cause)) return if (isCancel(cause)) return
error.value = cause instanceof Error ? cause.message : String(cause) error.value = cause instanceof Error ? cause.message : String(cause)
@@ -115,22 +117,22 @@ async function remove(id: string) {
<el-select :id="id" :model-value="settings[setting]" :disabled="busy" :loading="busy" filterable popper-class="rm-font-options" @change="choose"> <el-select :id="id" :model-value="settings[setting]" :disabled="busy" :loading="busy" filterable popper-class="rm-font-options" @change="choose">
<el-option v-for="font in fonts" :key="font.id" :label="font.name" :value="font.id"> <el-option v-for="font in fonts" :key="font.id" :label="font.name" :value="font.id">
<div class="rm-font-option"> <div class="rm-font-option">
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small> <span>{{ font.name }}</span><small v-if="font.id === 'maple'">{{ translate('common.default_844b8c') }}</small>
<el-button v-else text size="small" :aria-label="`删除字体 ${font.name}`" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)"> <el-button v-else text size="small" :aria-label="translate('common.delete_font_3a6b32', { p0: font.name })" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
<Icon name="trash" /> <Icon name="trash" />
</el-button> </el-button>
</div> </div>
</el-option> </el-option>
<template #footer> <template #footer>
<el-button class="rm-font-import" :disabled="busy" @click="fileInput!.click()"> <el-button class="rm-font-import" :disabled="busy" @click="fileInput!.click()">
<Icon name="import" />导入字体 <Icon name="import" />{{ translate('common.import_font_50751a') }}
</el-button> </el-button>
<p class="rm-share-note"> <p class="rm-share-note">
TTF、OTF、WOFF、WOFF2,最多 30 MB。 {{ translate('common.ttf_otf_woff_or_woff_0cc484') }}
</p> </p>
</template> </template>
</el-select> </el-select>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="`导入字体 ${id}`" @change="importFont"> <input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="translate('common.import_font_183cd9', { p0: id })" @change="importFont">
<p v-if="error" class="rm-auth-error" role="alert"> <p v-if="error" class="rm-auth-error" role="alert">
{{ error }} {{ error }}
</p> </p>
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InlineStyles } from '../../markdown/inline-styles' import type { InlineStyles } from '../../markdown/inline-styles'
import { t as translate } from '@@/i18n/runtime'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings' import { useSettingsStore } from '@/pinia/stores/settings'
@@ -44,49 +46,49 @@ function switchSize() {
</script> </script>
<template> <template>
<div class="rm-inline-appearance" role="region" aria-label="文字外观"> <div class="rm-inline-appearance" role="region" :aria-label="translate('common.text_appearance_09f8f2')">
<fieldset> <fieldset>
<legend>配色</legend><div class="rm-inline-swatches"> <legend>{{ translate('common.color_scheme_2ae75e') }}</legend><div class="rm-inline-swatches">
<button type="button" title="默认配色" aria-label="默认配色" @mousedown.prevent @click="emit('change', { 'color': undefined, 'background-color': undefined })"> <button type="button" :title="translate('common.default_color_scheme_7f0813')" :aria-label="translate('common.default_color_scheme_7f0813')" @mousedown.prevent @click="emit('change', { 'color': undefined, 'background-color': undefined })">
A A
</button> </button>
<button v-for="(color, index) in foreground" :key="index" type="button" :title="`配色 ${index + 1}`" :aria-label="`配色 ${index + 1}`" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })"> <button v-for="(color, index) in foreground" :key="index" type="button" :title="translate('common.color_scheme_d2f13c', { p0: index + 1 })" :aria-label="translate('common.color_scheme_d2f13c', { p0: index + 1 })" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">
A A
</button> </button>
</div> </div>
</fieldset> </fieldset>
<fieldset v-for="group in [{ key: 'color', title: '字体颜色', colors: foreground }, { key: 'background-color', title: '背景色', colors: background }]" :key="group.key"> <fieldset v-for="group in [{ key: 'color', get title() { return translate('common.text_color_fcaea5') }, colors: foreground }, { key: 'background-color', get title() { return translate('common.background_color_6d2b29') }, colors: background }]" :key="group.key">
<legend>{{ group.title }}</legend><div class="rm-inline-swatches"> <legend>{{ group.title }}</legend><div class="rm-inline-swatches">
<button type="button" :title="`默认${group.title}`" :aria-label="`默认${group.title}`" @mousedown.prevent @click="emit('change', { [group.key]: undefined })"> <button type="button" :title="translate('common.default_4a2e3e', { p0: group.title })" :aria-label="translate('common.default_4a2e3e', { p0: group.title })" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">
↺ ↺
</button> </button>
<button v-for="(color, index) in group.colors" :key="color" type="button" :title="`${group.title} ${index + 1}`" :aria-label="`${group.title} ${index + 1}`" :aria-pressed="styles[group.key as keyof InlineStyles] === color" :style="group.key === 'color' ? { color } : { backgroundColor: color, color: foreground[index] }" @mousedown.prevent @click="emit('change', { [group.key]: color })"> <button v-for="(color, index) in group.colors" :key="color" type="button" :title="`${group.title} ${index + 1}`" :aria-label="`${group.title} ${index + 1}`" :aria-pressed="styles[group.key as keyof InlineStyles] === color" :style="group.key === 'color' ? { color } : { backgroundColor: color, color: foreground[index] }" @mousedown.prevent @click="emit('change', { [group.key]: color })">
A A
</button> </button>
<label class="rm-inline-custom" :title="`自定义${group.title}`"><span>自定义</span><input type="color" :aria-label="`自定义${group.title}`" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })"></label> <label class="rm-inline-custom" :title="translate('common.custom_fc732e', { p0: group.title })"><span>{{ translate('common.custom_4eafa9') }}</span><input type="color" :aria-label="translate('common.custom_fc732e', { p0: group.title })" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })"></label>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>字体效果</legend><div class="rm-inline-effects"> <legend>{{ translate('common.text_effects_b8be49') }}</legend><div class="rm-inline-effects">
<button type="button" title="空心" aria-label="空心" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })"> <button type="button" :title="translate('common.outline_837aa5')" :aria-label="translate('common.outline_837aa5')" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">
A A
</button> </button>
<button type="button" title="投影" aria-label="投影" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })"> <button type="button" :title="translate('common.shadow_caabf3')" :aria-label="translate('common.shadow_caabf3')" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">
A A
</button> </button>
</div> </div>
</fieldset> </fieldset>
<label class="rm-inline-control">字体<select aria-label="局部字体" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">继承正文</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label> <label class="rm-inline-control">{{ translate('common.font_078838') }}<select :aria-label="translate('common.selection_font_3ff21e')" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">{{ translate('common.inherit_body_font_20476e') }}</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
<div class="rm-inline-control"> <div class="rm-inline-control">
<span>字号</span><label><input v-model="relative" type="checkbox" aria-label="相对字号" @change="switchSize"> 相对字号</label><el-button native-type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })"> <span>{{ translate('common.font_size_f71561') }}</span><label><input v-model="relative" type="checkbox" :aria-label="translate('common.relative_font_size_45afe1')" @change="switchSize"> {{ translate('common.relative_font_size_45afe1') }}</label><el-button native-type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">
默认 {{ translate('common.default_844b8c') }}
</el-button> </el-button>
</div> </div>
<form class="rm-inline-size" @submit.prevent="setSize"> <form class="rm-inline-size" @submit.prevent="setSize">
<input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号滑块" @change="setSize"><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号" @change="setSize"><span>{{ relative ? '%' : 'px' }}</span> <input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" :aria-label="translate('common.selection_font_size_slider_494d79')" @change="setSize"><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" :aria-label="translate('common.selection_font_size_5a5162')" @change="setSize"><span>{{ relative ? '%' : 'px' }}</span>
</form> </form>
<el-button native-type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')"> <el-button native-type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')">
清除样式 {{ translate('common.clear_appearance_384fbf') }}
</el-button> </el-button>
</div> </div>
</template> </template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Locale } from '@@/i18n/runtime'
import { locale } from '@@/i18n/runtime'
defineEmits<{ change: [value: Locale] }>()
</script>
<template>
<el-select :model-value="locale" aria-label="Language / 语言" style="width: 160px" @change="$emit('change', $event)">
<el-option label="English" value="en" />
<el-option label="简体中文" value="zh-CN" />
</el-select>
</template>
+124
View File
@@ -0,0 +1,124 @@
{
"retry_b8784c": "Retry",
"fonts_could_not_be_loaded_42a574": "Fonts could not be loaded. Check your connection and refresh the page.",
"refresh_page_9366e1": "Refresh page",
"invalid_font_catalog_b577df": "Invalid font catalog",
"manrope_sans_serif_a8923c": "Manrope · Sans serif",
"noto_serif_sc_serif_18133d": "Noto Serif SC · Serif",
"system_font_96a2a0": "System font",
"system_monospace_e32ca0": "System monospace",
"finish_saving_uploading_images_or_b9e96e": "Finish saving, uploading images, or working with files before updating the app",
"could_not_activate_the_new_c0fd5b": "Could not activate the new version",
"timed_out_waiting_for_the_57775d": "Timed out waiting for the new version",
"some_changes_or_operations_are_a5fda9": "Some changes or operations are still pending. Finish them before refreshing",
"cannot_update_right_now_please_c0de6a": "Cannot update right now. Please try again later",
"offline_notes_cannot_be_loaded_8ecb92": "Offline. Notes cannot be loaded or saved",
"update_available_daedea": "Update available",
"saving_6bdb44": "Saving…",
"save_and_refresh_d080ec": "Save and refresh",
"could_not_reload_settings_86605a": "; could not reload settings: {p0}",
"font_files_must_not_exceed_cc001c": "Font files must not exceed 30 MB",
"choose_a_ttf_otf_woff_eb6ab7": "Choose a TTF, OTF, WOFF, or WOFF2 font",
"could_not_read_this_font_ecc62f": "Could not read this font. Choose a valid font file.",
"font_imported_3dc232": "Font imported",
"font_deleted_its_uses_have_b83e92": "Font deleted. Its uses have been reset to Maple",
"default_844b8c": "Default",
"delete_font_3a6b32": "Delete font {p0}",
"import_font_50751a": "Import font",
"ttf_otf_woff_or_woff_0cc484": "TTF, OTF, WOFF, or WOFF2, up to 30 MB.",
"import_font_183cd9": "Import font {p0}",
"text_appearance_09f8f2": "Text appearance",
"color_scheme_2ae75e": "Color scheme",
"default_color_scheme_7f0813": "Default color scheme",
"color_scheme_d2f13c": "Color scheme {p0}",
"text_color_fcaea5": "Text color",
"background_color_6d2b29": "Background color",
"default_4a2e3e": "Default {p0}",
"custom_fc732e": "Custom {p0}",
"custom_4eafa9": "Custom",
"text_effects_b8be49": "Text effects",
"outline_837aa5": "Outline",
"shadow_caabf3": "Shadow",
"font_078838": "Font",
"selection_font_3ff21e": "Selection font",
"inherit_body_font_20476e": "Inherit body font",
"font_size_f71561": "Font size",
"relative_font_size_45afe1": "Relative font size",
"selection_font_size_slider_494d79": "Selection font size slider",
"selection_font_size_5a5162": "Selection font size",
"clear_appearance_384fbf": "Clear appearance",
"this_plugin_has_stopped_and_796b24": "This plugin has stopped and can no longer use the SDK",
"plugin_cleanup_failed_6388d7": "Plugin “{p0}” cleanup failed",
"plugin_cleanup_exceeded_seconds_32945c": "Plugin “{p0}” cleanup exceeded 5 seconds",
"failed_and_has_been_disabled_aa6412": "{p0} failed and has been disabled on this page. Retry in plugin settings",
"plugin_44a1b7": "Plugin “{p0}”: {p1}",
"invalid_plugin_registration_id_9702fe": "Invalid plugin registration ID",
"plugin_views_require_a_title_79ae1b": "Plugin views require a title and a mount function",
"view_1c5c06": "View",
"invalid_or_duplicate_command_f6503b": "Invalid or duplicate command",
"command_condition_df314d": "Command condition",
"command_928f87": "Command",
"a_code_block_renderer_must_952f83": "A code block renderer must be a function",
"code_block_rendering_1d4ae4": "Code block rendering",
"a_cleanup_callback_must_be_6ce04e": "A cleanup callback must be a function",
"invalid_vault_event_type_8621e4": "Invalid vault event type",
"vault_event_03582e": "Vault event",
"plugin_stopped_9ac692": "Plugin stopped",
"plugin_activation_exceeded_seconds_cffeae": "Plugin activation exceeded 10 seconds",
"a_plugin_must_export_an_1fbc50": "A plugin must export an activation function as its default export",
"activation_must_return_a_cleanup_ea3a8c": "Activation must return a cleanup function",
"activation_dd1286": "Activation",
"plugin_host_stopped_901067": "Plugin host stopped",
"plugin_refresh_superseded_2fa245": "Plugin refresh superseded",
"could_not_load_plugins_a1c343": "Could not load plugins: {p0}",
"editor_extension_28bb35": "Editor extension",
"plugin_view_rendering_exceeded_seconds_0d20d2": "Plugin view rendering exceeded 10 seconds",
"plugin_mount_must_return_a_c66e82": "Plugin mount must return a cleanup function",
"duplicate_plugin_registration_cannot_replace_fbc39b": "Duplicate plugin registration cannot replace existing functionality",
"invalid_code_block_language_name_8de01a": "Invalid code block language name",
"empty_diagram_37edb7": "Empty diagram",
"invalid_diagram_syntax_333e74": "Invalid diagram syntax",
"built_in_diagram_rendering_failed_e93cc6": "Built-in diagram rendering failed. Please retry",
"command_not_found_1cdb0c": "Command not found: {p0}",
"confirm_action_f8347f": "Confirm action",
"confirm_fac2a6": "Confirm",
"cancel_2cd0f3": "Cancel",
"invalid_shortcut_format_5e48e7": "Invalid shortcut format",
"space_bfa4a6": "Space",
"image_resource_authentication_is_not_73d285": "Image resource authentication is not ready",
"font_resource_authentication_is_not_796b9b": "Font resource authentication is not ready",
"invalid_server_response_protocol_194dc7": "Invalid server response protocol",
"request_failed_126fbf": "Request failed",
"request_failed_http_f21a09": "Request failed (HTTP {p0})",
"session_changed_073f28": "Session changed",
"sign_in_1e2df9": "Sign in",
"api_tokens_79db53": "API tokens",
"sign_out_498e1d": "Sign out",
"edit_051836": "Edit",
"delete_2f9daa": "Delete",
"restore_e0534b": "Restore",
"import_576d81": "Import",
"trash_ba35dc": "Trash",
"network_error_check_your_connection_566064": "Network error. Check your connection",
"name_d44e9b": "Name",
"copy_63d90d": "Copy",
"could_not_delete_9619d7": "Could not delete: {p0}",
"settings_df3d58": "Settings",
"search_results_88d72e": "Search results",
"appearance_86a63f": "Appearance",
"sign_out_3ab8cc": "Sign out",
"narrow_a614db": "Narrow",
"standard_6bea77": "Standard",
"wide_8dd966": "Wide",
"full_width_8ecc7d": "Full width",
"settings_saved_but_the_theme_9f87f8": "Settings saved, but the theme could not be applied: {p0}",
"invalid_shortcut_settings_2f07e6": "Invalid shortcut settings",
"could_not_save_settings_1cdd7f": "Could not save settings: {p0}",
"invalid_identity_information_e69b1a": "Invalid identity information",
"setup_65622e": "Setup",
"access_denied_25fb07": "Access denied",
"page_not_found_e75737": "Page not found",
"network_error": "Network error",
"move": "Move",
"network_or_local_storage_error": "Network or local storage error"
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { applyLocale, DEFAULT_LOCALE, locale, t } from './runtime'
const catalogs = import.meta.glob<Record<string, string>>(['./*.json', '../../pages/*/locales/*.json'], { eager: true, import: 'default' })
afterEach(() => applyLocale(DEFAULT_LOCALE))
it('默认英文,语言切换同时更新 HTML 语言与带参数提示', () => {
expect(DEFAULT_LOCALE).toBe('en')
applyLocale('en')
expect(document.documentElement.lang).toBe('en')
expect(t('common.retry_b8784c')).toBe('Retry')
expect(t('workspace.heading_a4f510', { p0: 2 })).toBe('Heading 2')
expect(t('workspace.notes_fd9460', { count: 1 })).toBe('1 note')
expect(t('workspace.notes_fd9460', { count: 2 })).toBe('2 notes')
expect(t('settings.permanently_deleted_with_failures', { count: 1, failed: 2 })).toBe('Permanently deleted 1 item; 2 failed')
applyLocale('zh-CN')
expect(locale.value).toBe('zh-CN')
expect(document.documentElement.lang).toBe('zh-CN')
expect(t('common.retry_b8784c')).toBe('重试')
expect(t('workspace.heading_a4f510', { p0: 2 })).toBe('设置 2 级标题')
expect(t('settings.permanently_deleted_with_failures', { count: 1, failed: 2 })).toBe('已永久删除 1 项,2 项失败')
})
it('每个模块的英文与中文消息键和插值参数一致', () => {
for (const [path, en] of Object.entries(catalogs).filter(([path]) => path.endsWith('/en.json'))) {
const zh = catalogs[path.replace('/en.json', '/zh-CN.json')]!
expect(Object.keys(en).sort(), path).toEqual(Object.keys(zh).sort())
for (const [key, value] of Object.entries(en)) {
expect(zh[key], `${path}: ${key}`).toBeTruthy()
expect([...new Set([...value.matchAll(/\{(\w+)\}/g)].map(m => m[1]))].sort(), key)
.toEqual([...new Set([...zh[key]!.matchAll(/\{(\w+)\}/g)].map(m => m[1]))].sort())
}
}
})
it('源码中使用的消息键均存在,默认英文不遗留中文界面文案', () => {
const sources = import.meta.glob<string>(['../../**/*.ts', '../../**/*.vue', '!../../**/*.test.ts'], { eager: true, query: '?raw', import: 'default' })
for (const [path, source] of Object.entries(sources)) {
for (const [, key] of source.matchAll(/(?:translate|\bt)\('((?:common|workspace|settings|login|error)\.[^']+)'/g)) {
const [module, id] = key!.split('.')
const catalogPath = module === 'common' ? './en.json' : `../../pages/${module}/locales/en.json`
expect(catalogs[catalogPath]?.[id!], `${path}: ${key}`).toBeTruthy()
}
}
for (const [path, catalog] of Object.entries(catalogs).filter(([path]) => path.endsWith('/en.json'))) {
expect(/[\u3400-\u9FFF]/.test(JSON.stringify(catalog)), path).toBe(false)
}
})
+36
View File
@@ -0,0 +1,36 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhCN from './zh-CN.json'
export const LOCALES = ['en', 'zh-CN'] as const
export type Locale = typeof LOCALES[number]
export const DEFAULT_LOCALE: Locale = 'en'
export function isLocale(value: unknown): value is Locale {
return value === 'en' || value === 'zh-CN'
}
const cached = typeof localStorage === 'undefined' ? {} : JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
const initialLocale = isLocale(cached.locale) ? cached.locale : DEFAULT_LOCALE
export const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: false,
messages: { 'en': { common: en }, 'zh-CN': { common: zhCN } },
})
export const locale = i18n.global.locale
export function t(key: string, params: Record<string, unknown> = {}): string {
return i18n.global.t(key, params)
}
export function applyLocale(value: Locale) {
locale.value = value
if (typeof document !== 'undefined') document.documentElement.lang = value
}
/** 登录页仅缓存显示语言;登录后由服务器账户设置覆盖。 */
export function chooseAnonymousLocale(value: Locale) {
const cached = JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
localStorage.setItem('rawmark.settings', JSON.stringify({ ...cached, locale: value }))
applyLocale(value)
}
applyLocale(initialLocale)
+124
View File
@@ -0,0 +1,124 @@
{
"retry_b8784c": "重试",
"fonts_could_not_be_loaded_42a574": "字体加载失败,请检查网络后刷新页面。",
"refresh_page_9366e1": "刷新页面",
"invalid_font_catalog_b577df": "字体列表格式无效",
"manrope_sans_serif_a8923c": "Manrope · 清晰无衬线",
"noto_serif_sc_serif_18133d": "Noto Serif SC · 思源宋体",
"system_font_96a2a0": "系统字体",
"system_monospace_e32ca0": "系统等宽字体",
"finish_saving_uploading_images_or_b9e96e": "请先完成保存、图片上传或文件操作,再更新应用",
"could_not_activate_the_new_c0fd5b": "新版本激活失败",
"timed_out_waiting_for_the_57775d": "等待新版本超时",
"some_changes_or_operations_are_a5fda9": "仍有未完成的保存或操作,请完成后再次点击刷新",
"cannot_update_right_now_please_c0de6a": "暂时无法更新,请稍后重试",
"offline_notes_cannot_be_loaded_8ecb92": "离线,无法加载或保存笔记",
"update_available_daedea": "有新版本",
"saving_6bdb44": "正在保存…",
"save_and_refresh_d080ec": "保存并刷新",
"could_not_reload_settings_86605a": ";重新读取设置失败:{p0}",
"font_files_must_not_exceed_cc001c": "字体文件超过 30 MB",
"choose_a_ttf_otf_woff_eb6ab7": "请选择 TTF、OTF、WOFF 或 WOFF2 字体",
"could_not_read_this_font_ecc62f": "无法解析字体文件,请选择有效的字体。",
"font_imported_3dc232": "字体已导入",
"font_deleted_its_uses_have_b83e92": "字体已删除,使用它的位置已恢复 Maple",
"default_844b8c": "默认",
"delete_font_3a6b32": "删除字体 {p0}",
"import_font_50751a": "导入字体",
"ttf_otf_woff_or_woff_0cc484": "TTF、OTF、WOFF、WOFF2,最多 30 MB。",
"import_font_183cd9": "导入字体 {p0}",
"text_appearance_09f8f2": "文字外观",
"color_scheme_2ae75e": "配色",
"default_color_scheme_7f0813": "默认配色",
"color_scheme_d2f13c": "配色 {p0}",
"text_color_fcaea5": "字体颜色",
"background_color_6d2b29": "背景色",
"default_4a2e3e": "默认{p0}",
"custom_fc732e": "自定义{p0}",
"custom_4eafa9": "自定义",
"text_effects_b8be49": "字体效果",
"outline_837aa5": "空心",
"shadow_caabf3": "投影",
"font_078838": "字体",
"selection_font_3ff21e": "局部字体",
"inherit_body_font_20476e": "继承正文",
"font_size_f71561": "字号",
"relative_font_size_45afe1": "相对字号",
"selection_font_size_slider_494d79": "局部字号滑块",
"selection_font_size_5a5162": "局部字号",
"clear_appearance_384fbf": "清除样式",
"this_plugin_has_stopped_and_796b24": "插件已停止,不能继续调用 SDK",
"plugin_cleanup_failed_6388d7": "插件“{p0}”清理失败",
"plugin_cleanup_exceeded_seconds_32945c": "插件“{p0}”清理超过 5 秒",
"failed_and_has_been_disabled_aa6412": "{p0}失败,已在当前页面停用;可在插件设置中重试",
"plugin_44a1b7": "插件“{p0}”:{p1}",
"invalid_plugin_registration_id_9702fe": "插件注册 id 无效",
"plugin_views_require_a_title_79ae1b": "插件视图必须提供标题与挂载函数",
"view_1c5c06": "视图",
"invalid_or_duplicate_command_f6503b": "命令无效或重复注册",
"command_condition_df314d": "命令条件",
"command_928f87": "命令",
"a_code_block_renderer_must_952f83": "代码块渲染器必须为函数",
"code_block_rendering_1d4ae4": "代码块渲染",
"a_cleanup_callback_must_be_6ce04e": "清理回调必须为函数",
"invalid_vault_event_type_8621e4": "仓库事件类型无效",
"vault_event_03582e": "仓库事件",
"plugin_stopped_9ac692": "插件已停止",
"plugin_activation_exceeded_seconds_cffeae": "插件激活超过 10 秒",
"a_plugin_must_export_an_1fbc50": "插件必须默认导出激活函数",
"activation_must_return_a_cleanup_ea3a8c": "激活返回值必须为清理函数",
"activation_dd1286": "激活",
"plugin_host_stopped_901067": "插件宿主已停止",
"plugin_refresh_superseded_2fa245": "插件刷新已过期",
"could_not_load_plugins_a1c343": "读取插件列表失败:{p0}",
"editor_extension_28bb35": "编辑器扩展",
"plugin_view_rendering_exceeded_seconds_0d20d2": "插件视图渲染超过 10 秒",
"plugin_mount_must_return_a_c66e82": "插件挂载返回值必须为清理函数",
"duplicate_plugin_registration_cannot_replace_fbc39b": "插件重复注册,不能覆盖已有功能",
"invalid_code_block_language_name_8de01a": "代码块语言名无效",
"empty_diagram_37edb7": "空图表",
"invalid_diagram_syntax_333e74": "图表语法错误",
"built_in_diagram_rendering_failed_e93cc6": "内置图表渲染失败,请重试",
"command_not_found_1cdb0c": "命令不存在:{p0}",
"confirm_action_f8347f": "确认操作",
"confirm_fac2a6": "确定",
"cancel_2cd0f3": "取消",
"invalid_shortcut_format_5e48e7": "快捷键格式无效",
"space_bfa4a6": "空格",
"image_resource_authentication_is_not_73d285": "图片资源认证尚未就绪",
"font_resource_authentication_is_not_796b9b": "字体资源认证尚未就绪",
"invalid_server_response_protocol_194dc7": "服务器响应协议无效",
"request_failed_126fbf": "请求失败",
"request_failed_http_f21a09": "请求失败(HTTP {p0})",
"session_changed_073f28": "会话已变化",
"sign_in_1e2df9": "登录",
"api_tokens_79db53": "API 令牌",
"sign_out_498e1d": "退出",
"edit_051836": "编辑",
"delete_2f9daa": "删除",
"restore_e0534b": "恢复",
"import_576d81": "导入",
"trash_ba35dc": "回收站",
"network_error_check_your_connection_566064": "网络错误,请检查连接",
"name_d44e9b": "名称",
"copy_63d90d": "复制",
"could_not_delete_9619d7": "删除失败:{p0}",
"settings_df3d58": "设置",
"search_results_88d72e": "搜索结果",
"appearance_86a63f": "外观",
"sign_out_3ab8cc": "退出登录",
"narrow_a614db": "窄",
"standard_6bea77": "标准",
"wide_8dd966": "宽",
"full_width_8ecc7d": "全宽",
"settings_saved_but_the_theme_9f87f8": "设置已保存,但主题应用失败:{p0}",
"invalid_shortcut_settings_2f07e6": "快捷键设置格式无效",
"could_not_save_settings_1cdd7f": "设置保存失败:{p0}",
"invalid_identity_information_e69b1a": "身份信息格式错误",
"setup_65622e": "初始化",
"access_denied_25fb07": "访问受限",
"page_not_found_e75737": "页面不存在",
"network_error": "网络错误",
"move": "移动",
"network_or_local_storage_error": "网络或本地存储错误"
}
+2 -2
View File
@@ -37,7 +37,7 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不
await vi.waitFor(() => expect(pluginState.status.fault?.cause).toBe(cause)) await vi.waitFor(() => expect(pluginState.status.fault?.cause).toBe(cause))
expect(pluginState.status.fault?.phase).toBe('error') expect(pluginState.status.fault?.phase).toBe('error')
expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false) expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false)
expect(notices.some(message => message.includes('命令失败'))).toBe(true) expect(notices.some(message => message.includes('Command failed'))).toBe(true)
list.mockClear() list.mockClear()
await retryPlugin('fault') await retryPlugin('fault')
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active')) await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
@@ -64,7 +64,7 @@ it('启动失败由入口通知一次,取消刷新不产生故障状态', asyn
await vi.waitFor(() => expect(notice).toHaveBeenCalledOnce()) await vi.waitFor(() => expect(notice).toHaveBeenCalledOnce())
list.mockResolvedValueOnce(apiSuccess({ plugins: [] })) list.mockResolvedValueOnce(apiSuccess({ plugins: [] }))
await refreshPlugins() await refreshPlugins()
list.mockRejectedValueOnce(new CanceledError('会话已变化')) list.mockRejectedValueOnce(new CanceledError('Session changed'))
await expect(refreshPlugins()).rejects.toSatisfy(isCancel) await expect(refreshPlugins()).rejects.toSatisfy(isCancel)
expect(pluginState.error).toBe('') expect(pluginState.error).toBe('')
expect(notice).toHaveBeenCalledOnce() expect(notice).toHaveBeenCalledOnce()
+29 -28
View File
@@ -2,6 +2,7 @@ import type { InstalledPlugin } from '@@/apis/plugins/type'
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api' import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api'
import { getPluginDataApi, getPluginsApi, savePluginDataApi } from '@@/apis/plugins' import { getPluginDataApi, getPluginsApi, savePluginDataApi } from '@@/apis/plugins'
import { getTreeApi } from '@@/apis/tree' import { getTreeApi } from '@@/apis/tree'
import { t as translate } from '@@/i18n/runtime'
import { readNoteFile } from '@@/utils/note-files' import { readNoteFile } from '@@/utils/note-files'
import { pluginResourceURL } from '@@/utils/resource-tickets' import { pluginResourceURL } from '@@/utils/resource-tickets'
@@ -35,12 +36,12 @@ function status(id: string, phase: string, error = '', cause?: unknown) {
pluginState.status[id] = { phase, error, cause, cleanupErrors: pluginState.status[id]?.cleanupErrors } pluginState.status[id] = { phase, error, cause, cleanupErrors: pluginState.status[id]?.cleanupErrors }
} }
function assertActive(instance: Instance) { function assertActive(instance: Instance) {
if (instance.stopped || !running) throw new Error('插件已停止,不能继续调用 SDK') if (instance.stopped || !running) throw new Error(translate('common.this_plugin_has_stopped_and_796b24'))
} }
function cleanupFailure(instance: Instance, cause: unknown) { function cleanupFailure(instance: Instance, cause: unknown) {
instance.cleanupErrors.push(cause) instance.cleanupErrors.push(cause)
adapter.notice(`插件“${instance.info.name}”清理失败`) adapter.notice(translate('common.plugin_cleanup_failed_6388d7', { p0: instance.info.name }))
} }
function track(instance: Instance, cleanup: AsyncCleanup): () => void { function track(instance: Instance, cleanup: AsyncCleanup): () => void {
@@ -75,27 +76,27 @@ async function stop(instance: Instance) {
}), }),
]) ])
clearTimeout(timer!) clearTimeout(timer!)
if (!finished) adapter.notice(`插件“${instance.info.name}”清理超过 5 秒`) if (!finished) adapter.notice(translate('common.plugin_cleanup_exceeded_seconds_32945c', { p0: instance.info.name }))
} }
function fail(instance: Instance, phase: string, cause: unknown) { function fail(instance: Instance, phase: string, cause: unknown) {
if (instance.stopped) return if (instance.stopped) return
const message = `${phase}失败,已在当前页面停用;可在插件设置中重试` const message = translate('common.failed_and_has_been_disabled_aa6412', { p0: phase })
faults.set(instance.info.id, instance.info.revision) faults.set(instance.info.id, instance.info.revision)
status(instance.info.id, 'error', message, cause) status(instance.info.id, 'error', message, cause)
adapter.notice(`插件“${instance.info.name}”:${message}`) adapter.notice(translate('common.plugin_44a1b7', { p0: instance.info.name, p1: message }))
void stop(instance) void stop(instance)
} }
function localID(instance: Instance, id: string) { function localID(instance: Instance, id: string) {
if (!/^[a-z0-9][\w.-]{0,63}$/i.test(id)) throw new Error('插件注册 id 无效') if (!/^[a-z0-9][\w.-]{0,63}$/i.test(id)) throw new Error(translate('common.invalid_plugin_registration_id_9702fe'))
return `plugin:${instance.info.id}:${id}` return `plugin:${instance.info.id}:${id}`
} }
function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) { function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) {
assertActive(instance) assertActive(instance)
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error('插件视图必须提供标题与挂载函数') if (!view.title.trim() || typeof view.mount !== 'function') throw new Error(translate('common.plugin_views_require_a_title_79ae1b'))
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: cause => fail(instance, '视图', cause) })) return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: cause => fail(instance, translate('common.view_1c5c06'), cause) }))
} }
async function createAPI(instance: Instance): Promise<PluginAPI> { async function createAPI(instance: Instance): Promise<PluginAPI> {
@@ -109,20 +110,20 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
addCommand(command) { addCommand(command) {
check() check()
const commandID = localID(instance, command.id) const commandID = localID(instance, command.id)
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some(c => c.id === commandID)) throw new Error('命令无效或重复注册') if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some(c => c.id === commandID)) throw new Error(translate('common.invalid_or_duplicate_command_f6503b'))
return track(instance, registerCommand({ ...command, id: commandID, keys: [], when: () => { return track(instance, registerCommand({ ...command, id: commandID, keys: [], when: () => {
if (instance.stopped) return false if (instance.stopped) return false
try { try {
return command.when?.() ?? true return command.when?.() ?? true
} catch (cause) { } catch (cause) {
fail(instance, '命令条件', cause); return false fail(instance, translate('common.command_condition_df314d'), cause); return false
} }
}, run: () => { }, run: () => {
if (instance.stopped) return if (instance.stopped) return
try { try {
void Promise.resolve(command.run()).catch(cause => fail(instance, '命令', cause)) void Promise.resolve(command.run()).catch(cause => fail(instance, translate('common.command_928f87'), cause))
} catch (cause) { } catch (cause) {
fail(instance, '命令', cause) fail(instance, translate('common.command_928f87'), cause)
} }
} })) } }))
}, },
@@ -136,13 +137,13 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
}, },
registerCodeBlockRenderer(language, render) { registerCodeBlockRenderer(language, render) {
check() check()
if (typeof render !== 'function') throw new Error('代码块渲染器必须为函数') if (typeof render !== 'function') throw new Error(translate('common.a_code_block_renderer_must_952f83'))
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: cause => fail(instance, '代码块渲染', cause) })) return track(instance, registerCodeRenderer(language, { owner: id, render, fail: cause => fail(instance, translate('common.code_block_rendering_1d4ae4'), cause) }))
}, },
addSidebarView: view => addView(instance, sidebarViews, view), addSidebarView: view => addView(instance, sidebarViews, view),
addSettingTab: view => addView(instance, settingTabs, view), addSettingTab: view => addView(instance, settingTabs, view),
registerCleanup: (cleanup) => { registerCleanup: (cleanup) => {
if (typeof cleanup !== 'function') throw new Error('清理回调必须为函数') if (typeof cleanup !== 'function') throw new Error(translate('common.a_cleanup_callback_must_be_6ce04e'))
return track(instance, cleanup) return track(instance, cleanup)
}, },
vault: { vault: {
@@ -157,13 +158,13 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
}, },
on<K extends keyof VaultEvents>(event: K, callback: (value: VaultEvents[K]) => void | Promise<void>) { on<K extends keyof VaultEvents>(event: K, callback: (value: VaultEvents[K]) => void | Promise<void>) {
check() check()
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error('仓库事件类型无效') if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error(translate('common.invalid_vault_event_type_8621e4'))
const listener = (e: Event) => { const listener = (e: Event) => {
if (instance.stopped) return if (instance.stopped) return
try { try {
void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(cause => fail(instance, '仓库事件', cause)) void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(cause => fail(instance, translate('common.vault_event_03582e'), cause))
} catch (cause) { } catch (cause) {
fail(instance, '仓库事件', cause) fail(instance, translate('common.vault_event_03582e'), cause)
} }
} }
pluginEvents.addEventListener(event, listener) pluginEvents.addEventListener(event, listener)
@@ -197,9 +198,9 @@ async function activate(instance: Instance) {
const signal = instance.controller.signal const signal = instance.controller.signal
let onAbort: () => void = () => {} let onAbort: () => void = () => {}
const interrupted = new Promise<never>((_, reject) => { const interrupted = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error('插件已停止')) onAbort = () => reject(new Error(translate('common.plugin_stopped_9ac692')))
signal.addEventListener('abort', onAbort, { once: true }) signal.addEventListener('abort', onAbort, { once: true })
timer = setTimeout(() => reject(new Error('插件激活超过 10 秒')), 10_000) timer = setTimeout(() => reject(new Error(translate('common.plugin_activation_exceeded_seconds_cffeae'))), 10_000)
}) })
const pending = (async () => { const pending = (async () => {
const sdk = await createAPI(instance) const sdk = await createAPI(instance)
@@ -209,9 +210,9 @@ async function activate(instance: Instance) {
assertActive(instance) assertActive(instance)
const module = await import(/* @vite-ignore */ url) const module = await import(/* @vite-ignore */ url)
assertActive(instance) assertActive(instance)
if (typeof module.default !== 'function') throw new Error('插件必须默认导出激活函数') if (typeof module.default !== 'function') throw new Error(translate('common.a_plugin_must_export_an_1fbc50'))
const cleanup: unknown = await module.default(sdk) const cleanup: unknown = await module.default(sdk)
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error('激活返回值必须为清理函数') if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error(translate('common.activation_must_return_a_cleanup_ea3a8c'))
if (cleanup) { if (cleanup) {
if (instance.stopped) { if (instance.stopped) {
try { try {
@@ -228,7 +229,7 @@ async function activate(instance: Instance) {
await Promise.race([pending, interrupted]) await Promise.race([pending, interrupted])
if (!instance.stopped) status(instance.info.id, 'active') if (!instance.stopped) status(instance.info.id, 'active')
} catch (cause) { } catch (cause) {
fail(instance, '激活', cause) fail(instance, translate('common.activation_dd1286'), cause)
} finally { } finally {
clearTimeout(timer!) clearTimeout(timer!)
signal.removeEventListener('abort', onAbort) signal.removeEventListener('abort', onAbort)
@@ -256,12 +257,12 @@ async function pump() {
} }
export async function refreshPlugins() { export async function refreshPlugins() {
if (!running) throw new CanceledError('插件宿主已停止') if (!running) throw new CanceledError(translate('common.plugin_host_stopped_901067'))
const currentSession = session const currentSession = session
const sequence = ++request const sequence = ++request
try { try {
const installed = (await getPluginsApi()).data.plugins const installed = (await getPluginsApi()).data.plugins
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期') if (!running || currentSession !== session || sequence !== request) throw new CanceledError(translate('common.plugin_refresh_superseded_2fa245'))
pluginState.installed = installed pluginState.installed = installed
pluginState.error = '' pluginState.error = ''
pluginState.errorCause = undefined pluginState.errorCause = undefined
@@ -277,9 +278,9 @@ export async function refreshPlugins() {
} }
void pump() void pump()
} catch (cause) { } catch (cause) {
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期') if (!running || currentSession !== session || sequence !== request) throw new CanceledError(translate('common.plugin_refresh_superseded_2fa245'))
if (isCancel(cause)) throw cause if (isCancel(cause)) throw cause
pluginState.error = `读取插件列表失败:${cause instanceof Error ? cause.message : String(cause)}` pluginState.error = translate('common.could_not_load_plugins_a1c343', { p0: cause instanceof Error ? cause.message : String(cause) })
pluginState.errorCause = cause pluginState.errorCause = cause
for (const instance of instances.values()) { for (const instance of instances.values()) {
status(instance.info.id, 'paused') status(instance.info.id, 'paused')
@@ -319,7 +320,7 @@ export function pluginEditorFailure(error: unknown) {
queueMicrotask(() => { queueMicrotask(() => {
for (const owner of owners) { for (const owner of owners) {
const instance = instances.get(owner) const instance = instances.get(owner)
if (instance) fail(instance, '编辑器扩展', error) if (instance) fail(instance, translate('common.editor_extension_28bb35'), error)
} }
}) })
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import type { AsyncCleanup, Awaitable, MountContext } from '@@/plugin-api' import type { AsyncCleanup, Awaitable, MountContext } from '@@/plugin-api'
import { t as translate } from '@@/i18n/runtime'
// 每次挂载使用独立容器;超时或停用后的异步渲染只能修改已脱离页面的容器。 // 每次挂载使用独立容器;超时或停用后的异步渲染只能修改已脱离页面的容器。
export function mountContent( export function mountContent(
@@ -30,14 +31,14 @@ export function mountContent(
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!pending || stopped) return if (!pending || stopped) return
stop() stop()
fail(new Error('插件视图渲染超过 10 秒')) fail(new Error(translate('common.plugin_view_rendering_exceeded_seconds_0d20d2')))
}, 10_000) }, 10_000)
void Promise.resolve().then(() => { void Promise.resolve().then(() => {
if (!stopped) return mount({ container, signal: controller.signal }) if (!stopped) return mount({ container, signal: controller.signal })
}).then((result) => { }).then((result) => {
pending = false pending = false
clearTimeout(timer) clearTimeout(timer)
if (result !== undefined && typeof result !== 'function') throw new Error('插件挂载返回值必须为清理函数') if (result !== undefined && typeof result !== 'function') throw new Error(translate('common.plugin_mount_must_return_a_c66e82'))
if (stopped) { if (stopped) {
if (result) runCleanup(result) if (result) runCleanup(result)
} else { } else {
+8 -5
View File
@@ -1,6 +1,7 @@
import type { CodeBlockRenderer, PluginView } from '@@/plugin-api' import type { CodeBlockRenderer, PluginView } from '@@/plugin-api'
import type { Extension } from '@codemirror/state' import type { Extension } from '@codemirror/state'
import type { MarkdownExtension } from '@lezer/markdown' import type { MarkdownExtension } from '@lezer/markdown'
import { t as translate } from '@@/i18n/runtime'
import { Facet, StateEffect } from '@codemirror/state' import { Facet, StateEffect } from '@codemirror/state'
import { shallowReactive } from 'vue' import { shallowReactive } from 'vue'
import { renderMermaid } from '../markdown/render' import { renderMermaid } from '../markdown/render'
@@ -33,7 +34,7 @@ export function pluginsChanged() {
} }
export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => void { export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => void {
if (map.has(key)) throw new Error('插件重复注册,不能覆盖已有功能') if (map.has(key)) throw new Error(translate('common.duplicate_plugin_registration_cannot_replace_fbc39b'))
map.set(key, value) map.set(key, value)
try { try {
pluginsChanged() pluginsChanged()
@@ -49,7 +50,7 @@ export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => voi
export function registerCodeRenderer(language: string, value: OwnedRenderer) { export function registerCodeRenderer(language: string, value: OwnedRenderer) {
const key = language.trim().toLowerCase() const key = language.trim().toLowerCase()
if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error('代码块语言名无效') if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error(translate('common.invalid_code_block_language_name_8de01a'))
return registerEntry(codeRenderers, key, value) return registerEntry(codeRenderers, key, value)
} }
@@ -57,7 +58,7 @@ registerCodeRenderer('mermaid', {
owner: 'rawmark-mermaid', owner: 'rawmark-mermaid',
async render({ source, container, signal }) { async render({ source, container, signal }) {
if (!source.trim()) { if (!source.trim()) {
container.textContent = '空图表'; return container.textContent = translate('common.empty_diagram_37edb7'); return
} }
try { try {
const svg = await renderMermaid(source) const svg = await renderMermaid(source)
@@ -65,8 +66,10 @@ registerCodeRenderer('mermaid', {
} catch (error) { } catch (error) {
if (signal.aborted) return if (signal.aborted) return
container.className = 'cm-rm-render-error' container.className = 'cm-rm-render-error'
container.textContent = error instanceof Error ? error.message : '图表语法错误' container.textContent = error instanceof Error ? error.message : translate('common.invalid_diagram_syntax_333e74')
} }
}, },
fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { detail: '内置图表渲染失败,请重试' })), fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { get detail() {
return translate('common.built_in_diagram_rendering_failed_e93cc6')
} })),
}) })
+2 -1
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令 // 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
import { shallowReactive } from 'vue' import { shallowReactive } from 'vue'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
@@ -55,7 +56,7 @@ export function commandKeys(command: Command): string[] {
export function commandTitle(id: string): string { export function commandTitle(id: string): string {
const command = commands.get(id) const command = commands.get(id)
if (!command) throw new Error(`命令不存在:${id}`) if (!command) throw new Error(translate('common.command_not_found_1cdb0c', { p0: id }))
const keys = commandKeys(command) const keys = commandKeys(command)
return command.title + (keys.length ? `(${keys.map(formatKeys).join(' / ')})` : '') return command.title + (keys.length ? `(${keys.map(formatKeys).join(' / ')})` : '')
} }
+7 -2
View File
@@ -1,13 +1,18 @@
import { t as translate } from '@@/i18n/runtime'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
/** 确认等待期间换会话时,旧操作视为取消。 */ /** 确认等待期间换会话时,旧操作视为取消。 */
export async function confirmAction(message: string, title = '确认操作'): Promise<boolean> { export async function confirmAction(message: string, title = translate('common.confirm_action_f8347f')): Promise<boolean> {
const user = useUserStore(pinia) const user = useUserStore(pinia)
const version = user.getSessionVersion() const version = user.getSessionVersion()
try { try {
await ElMessageBox.confirm(message, title, { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', distinguishCancelAndClose: true }) await ElMessageBox.confirm(message, title, { get confirmButtonText() {
return translate('common.confirm_fac2a6')
}, get cancelButtonText() {
return translate('common.cancel_2cd0f3')
}, type: 'warning', distinguishCancelAndClose: true })
return version === user.getSessionVersion() return version === user.getSessionVersion()
} catch (cause) { } catch (cause) {
if (cause === 'cancel' || cause === 'close') return false if (cause === 'cancel' || cause === 'close') return false
+5 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 应用、编辑器和快捷键录入共用同一套按键表示。 // 应用、编辑器和快捷键录入共用同一套按键表示。
export const isMac = /Mac|iPhone|iPad/.test(navigator.platform) export const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
const punctuation: Record<string, string> = { BracketLeft: '[', BracketRight: ']', Backquote: '`' } const punctuation: Record<string, string> = { BracketLeft: '[', BracketRight: ']', Backquote: '`' }
@@ -13,7 +14,7 @@ function physicalCharacter(event: KeyboardEvent): string | undefined {
export function normalizeKeys(value: string): string { export function normalizeKeys(value: string): string {
const parts = value.split('+') const parts = value.split('+')
const key = parts.pop() const key = parts.pop()
if (!key || parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error('快捷键格式无效') if (!key || parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error(translate('common.invalid_shortcut_format_5e48e7'))
return [...modifiers.filter(part => parts.includes(part)), key.length === 1 ? key.toUpperCase() : key].join('+') return [...modifiers.filter(part => parts.includes(part)), key.length === 1 ? key.toUpperCase() : key].join('+')
} }
@@ -44,5 +45,7 @@ export function recordedKeys(event: KeyboardEvent): string | null {
} }
export function formatKeys(value: string): string { export function formatKeys(value: string): string {
return value.split('+').map(part => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', Space: '空格', Plus: '+' })[part] ?? part).join(isMac ? '' : '+') return value.split('+').map(part => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', get Space() {
return translate('common.space_bfa4a6')
}, Plus: '+' })[part] ?? part).join(isMac ? '' : '+')
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import { createResourceTicketApi } from '@@/apis/auth' import { createResourceTicketApi } from '@@/apis/auth'
import { t as translate } from '@@/i18n/runtime'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
@@ -28,11 +29,11 @@ export async function prepareResources() {
user.assertSession(current) user.assertSession(current)
} }
export function imageResourceRoot(): string { export function imageResourceRoot(): string {
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('图片资源认证尚未就绪') if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error(translate('common.image_resource_authentication_is_not_73d285'))
return imageRoot return imageRoot
} }
export function fontResourceRoot(): string { export function fontResourceRoot(): string {
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('字体资源认证尚未就绪') if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error(translate('common.font_resource_authentication_is_not_796b9b'))
return fontRoot return fontRoot
} }
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> { export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {
+12
View File
@@ -1,3 +1,4 @@
import { applyLocale } from '@@/i18n/runtime'
import { readNoteFile } from '@@/utils/note-files' import { readNoteFile } from '@@/utils/note-files'
import { toasts } from '@@/utils/notifications' import { toasts } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
@@ -13,6 +14,7 @@ const user = useUserStore(pinia)
const permission = usePermissionStore(pinia) const permission = usePermissionStore(pinia)
const payload = (data: unknown, status = 200, code = 0, message = '成功') => new Response(JSON.stringify({ code, data, message }), { status, headers: { 'Content-Type': 'application/json' } }) const payload = (data: unknown, status = 200, code = 0, message = '成功') => new Response(JSON.stringify({ code, data, message }), { status, headers: { 'Content-Type': 'application/json' } })
beforeEach(() => { beforeEach(() => {
applyLocale('en')
user.setToken('rms_test'); toasts.splice(0) user.setToken('rms_test'); toasts.splice(0)
}) })
afterEach(() => { afterEach(() => {
@@ -24,6 +26,7 @@ it('使用 Bearer 和统一 JSON,省略 Cookie 且禁止缓存', async () => {
expect(await request({ method: 'post', url: '/test', data: { a: 1 } })).toEqual({ code: 0, data: { path: 'a.md' }, message: '成功' }) expect(await request({ method: 'post', url: '/test', data: { a: 1 } })).toEqual({ code: 0, data: { path: 'a.md' }, message: '成功' })
const req = fetch.mock.calls[0]![0] as Request const req = fetch.mock.calls[0]![0] as Request
expect(req.headers.get('Authorization')).toBe('Bearer rms_test') expect(req.headers.get('Authorization')).toBe('Bearer rms_test')
expect(req.headers.get('Accept-Language')).toBe('en')
expect(req.credentials).toBe('omit') expect(req.credentials).toBe('omit')
expect(fetch.mock.calls[0]![1]).toEqual(expect.objectContaining({ cache: 'no-store' })) expect(fetch.mock.calls[0]![1]).toEqual(expect.objectContaining({ cache: 'no-store' }))
expect(await req.text()).toBe('{"a":1}') expect(await req.text()).toBe('{"a":1}')
@@ -110,3 +113,12 @@ it('读取笔记的原始响应解码期间切换会话,旧正文仍须拒绝'
expect(user.token).toBe('rms_new') expect(user.token).toBe('rms_new')
expect(toasts).toHaveLength(0) expect(toasts).toHaveLength(0)
}) })
it('语言切换仅影响后续请求,不取消业务请求或改变会话', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ ok: true }))
const version = user.getSessionVersion()
applyLocale('zh-CN')
await request({ method: 'get', url: '/localized' })
expect((fetch.mock.calls[0]![0] as Request).headers.get('Accept-Language')).toBe('zh-CN')
expect(user.getSessionVersion()).toBe(version)
})
+6 -4
View File
@@ -1,4 +1,5 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios' import type { AxiosRequestConfig, AxiosResponse } from 'axios'
import { locale, t as translate } from '@@/i18n/runtime'
import { reportError } from '@@/utils/notifications' import { reportError } from '@@/utils/notifications'
import axios, { AxiosHeaders, CanceledError, isAxiosError, isCancel } from 'axios' import axios, { AxiosHeaders, CanceledError, isAxiosError, isCancel } from 'axios'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
@@ -19,7 +20,7 @@ const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCreden
function envelope(value: unknown, status: number): ApiResponseData<unknown> { function envelope(value: unknown, status: number): ApiResponseData<unknown> {
if (!value || typeof value !== 'object' || !('code' in value) || typeof value.code !== 'number' if (!value || typeof value !== 'object' || !('code' in value) || typeof value.code !== 'number'
|| !Number.isInteger(value.code) || !('data' in value) || !('message' in value) || typeof value.message !== 'string') { || !Number.isInteger(value.code) || !('data' in value) || !('message' in value) || typeof value.message !== 'string') {
throw new ApiError(status, '服务器响应协议无效') throw new ApiError(status, translate('common.invalid_server_response_protocol_194dc7'))
} }
return value as ApiResponseData<unknown> return value as ApiResponseData<unknown>
} }
@@ -27,7 +28,7 @@ function errorData(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {} return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
} }
async function failure(error: unknown): Promise<ApiError> { async function failure(error: unknown): Promise<ApiError> {
if (!isAxiosError(error) || !error.response) return error instanceof ApiError ? error : new ApiError(0, error instanceof Error ? error.message : '请求失败') if (!isAxiosError(error) || !error.response) return error instanceof ApiError ? error : new ApiError(0, error instanceof Error ? error.message : translate('common.request_failed_126fbf'))
const response = error.response const response = error.response
let body: unknown = response.data let body: unknown = response.data
if (body instanceof Blob) body = await body.text() if (body instanceof Blob) body = await body.text()
@@ -35,7 +36,7 @@ async function failure(error: unknown): Promise<ApiError> {
try { try {
body = JSON.parse(body) body = JSON.parse(body)
} catch { } catch {
return new ApiError(response.status, `请求失败(HTTP ${response.status})`) return new ApiError(response.status, translate('common.request_failed_http_f21a09', { p0: response.status }))
} }
} }
try { try {
@@ -53,6 +54,7 @@ export async function request<T>(config: RequestConfig): Promise<T | AxiosRespon
const version = user.getSessionVersion() const version = user.getSessionVersion()
const token = config.anonymous === true ? '' : user.token const token = config.anonymous === true ? '' : user.token
const headers = new AxiosHeaders(config.headers as ConstructorParameters<typeof AxiosHeaders>[0]) const headers = new AxiosHeaders(config.headers as ConstructorParameters<typeof AxiosHeaders>[0])
headers.set('Accept-Language', locale.value)
headers.set('Authorization', token ? `Bearer ${token}` : undefined) headers.set('Authorization', token ? `Bearer ${token}` : undefined)
try { try {
const response = await instance.request<T, AxiosResponse<T, unknown, AxiosHeaders>>({ const response = await instance.request<T, AxiosResponse<T, unknown, AxiosHeaders>>({
@@ -70,7 +72,7 @@ export async function request<T>(config: RequestConfig): Promise<T | AxiosRespon
return config.returnResponse ? response : response.data return config.returnResponse ? response : response.data
} catch (cause) { } catch (cause) {
if (isCancel(cause)) throw cause if (isCancel(cause)) throw cause
if (version !== user.getSessionVersion()) throw new CanceledError('会话已变化') if (version !== user.getSessionVersion()) throw new CanceledError(translate('common.session_changed_073f28'))
const error = await failure(cause) const error = await failure(cause)
user.assertSession(version) user.assertSession(version)
if (error.status === 401 && token) user.invalidate(version) if (error.status === 401 && token) user.invalidate(version)
+13
View File
@@ -0,0 +1,13 @@
import { i18n } from '@@/i18n/runtime'
import errorEn from './pages/error/locales/en.json'
import errorZh from './pages/error/locales/zh-CN.json'
import loginEn from './pages/login/locales/en.json'
import loginZh from './pages/login/locales/zh-CN.json'
import settingsEn from './pages/settings/locales/en.json'
import settingsZh from './pages/settings/locales/zh-CN.json'
import workspaceEn from './pages/workspace/locales/en.json'
import workspaceZh from './pages/workspace/locales/zh-CN.json'
i18n.global.mergeLocaleMessage('en', { error: errorEn, login: loginEn, settings: settingsEn, workspace: workspaceEn })
i18n.global.mergeLocaleMessage('zh-CN', { error: errorZh, login: loginZh, settings: settingsZh, workspace: workspaceZh })
export { i18n }
+2 -1
View File
@@ -6,6 +6,7 @@ import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { router } from '@/router' import { router } from '@/router'
import App from './App.vue' import App from './App.vue'
import { i18n } from './i18n'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import '@@/appearance/base.css' import '@@/appearance/base.css'
import '@@/assets/styles/app.css' import '@@/assets/styles/app.css'
@@ -14,7 +15,7 @@ import 'virtual:uno.css'
watchFontLoading() watchFontLoading()
const app = createApp(App) const app = createApp(App)
app.use(pinia).use(router) app.use(i18n).use(pinia).use(router)
const user = useUserStore(pinia) const user = useUserStore(pinia)
window.addEventListener('storage', user.syncStorage) window.addEventListener('storage', user.syncStorage)
watch(() => user.sessionVersion, () => { watch(() => user.sessionVersion, () => {
+5 -1
View File
@@ -1,3 +1,7 @@
<script setup lang="ts">
import { t as translate } from '@@/i18n/runtime'
</script>
<template> <template>
<div class="error"> <div class="error">
<div class="error-svg"> <div class="error-svg">
@@ -5,7 +9,7 @@
</div> </div>
<router-link to="/"> <router-link to="/">
<el-button type="primary"> <el-button type="primary">
回到首页 {{ translate('error.back_to_home_6774a7') }}
</el-button> </el-button>
</router-link> </router-link>
</div> </div>
+3
View File
@@ -0,0 +1,3 @@
{
"back_to_home_6774a7": "Back to home"
}
+3
View File
@@ -0,0 +1,3 @@
{
"back_to_home_6774a7": "回到首页"
}
+24 -15
View File
@@ -4,7 +4,10 @@ import type { Captcha } from '@@/apis/auth/type'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { getCaptchaApi, loginApi, setupApi } from '@@/apis/auth' import { getCaptchaApi, loginApi, setupApi } from '@@/apis/auth'
import AppStatus from '@@/components/AppStatus/index.vue' import AppStatus from '@@/components/AppStatus/index.vue'
import LocaleSelect from '@@/components/LocaleSelect/index.vue'
import { chooseAnonymousLocale, locale, t as translate } from '@@/i18n/runtime'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -18,11 +21,15 @@ const user = useUserStore()
const isSetup = computed(() => route.name === 'Setup') const isSetup = computed(() => route.name === 'Setup')
const form = ref<FormInstance>() const form = ref<FormInstance>()
const fields = reactive({ password: '', confirm: '', captchaCode: '' }) const fields = reactive({ password: '', confirm: '', captchaCode: '' })
const rules: FormRules<typeof fields> = { const rules = computed<FormRules<typeof fields>>(() => ({
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { validator: (_rule, value: string, callback) => callback(isSetup.value && value.length < 8 ? new Error('密码至少 8 位') : undefined), trigger: 'blur' }], password: [{ required: true, get message() {
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.password ? new Error('两次输入的密码不一致') : undefined), trigger: 'blur' }], return translate('login.enter_your_password_728a7b')
captchaCode: [{ pattern: /^\d{6}$/, required: true, message: '请输入六位数字验证码', trigger: 'blur' }], }, trigger: 'blur' }, { validator: (_rule, value: string, callback) => callback(isSetup.value && value.length < 8 ? new Error(translate('login.password_must_contain_at_least_8c1f11')) : undefined), trigger: 'blur' }],
} confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.password ? new Error(translate('login.passwords_do_not_match_588077')) : undefined), trigger: 'blur' }],
captchaCode: [{ pattern: /^\d{6}$/, required: true, get message() {
return translate('login.enter_the_six_digit_verification_d746b8')
}, trigger: 'blur' }],
}))
const captcha = ref<Captcha>() const captcha = ref<Captcha>()
const captchaError = ref('') const captchaError = ref('')
const error = ref('') const error = ref('')
@@ -45,6 +52,7 @@ async function refresh() {
if (id === refreshID) refreshing.value = false if (id === refreshID) refreshing.value = false
} }
} }
watch(locale, () => form.value?.clearValidate())
watch(isSetup, (setup) => { watch(isSetup, (setup) => {
++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh() ++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh()
form.value?.clearValidate() form.value?.clearValidate()
@@ -61,7 +69,7 @@ async function submit() {
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
user.assertSession(owner) user.assertSession(owner)
const result = isSetup.value ? (await setupApi({ password: fields.password })).data : (await loginApi({ password: fields.password, captchaId: captcha.value!.captchaId, captchaCode: fields.captchaCode })).data const result = isSetup.value ? (await setupApi({ password: fields.password })).data : (await loginApi({ password: fields.password, captchaId: captcha.value!.captchaId, captchaCode: fields.captchaCode })).data
if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('登录响应格式错误') if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error(translate('login.invalid_login_response_b83520'))
user.setToken(result.token) user.setToken(result.token)
const version = user.getSessionVersion() const version = user.getSessionVersion()
await user.getInfo() await user.getInfo()
@@ -83,25 +91,26 @@ async function submit() {
<div class="rm-auth"> <div class="rm-auth">
<el-form ref="form" class="rm-auth-card" :model="fields" :rules="rules" label-position="top" :disabled="busy" @submit.prevent="submit"> <el-form ref="form" class="rm-auth-card" :model="fields" :rules="rules" label-position="top" :disabled="busy" @submit.prevent="submit">
<h1>RawMark</h1> <h1>RawMark</h1>
<LocaleSelect @change="chooseAnonymousLocale" />
<p class="rm-auth-hint"> <p class="rm-auth-hint">
{{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }} {{ isSetup ? translate('login.welcome_set_a_password_to_cb8457') : translate('login.sign_in_to_your_markdown_4bb381') }}
</p> </p>
<el-form-item label="密码" prop="password"> <el-form-item :label="translate('login.password_a621ab')" prop="password">
<el-input v-model="fields.password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" autofocus show-password /> <el-input v-model="fields.password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" autofocus show-password />
</el-form-item> </el-form-item>
<el-form-item v-if="isSetup" label="确认密码" prop="confirm"> <el-form-item v-if="isSetup" :label="translate('login.confirm_password_81b17b')" prop="confirm">
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password /> <el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
</el-form-item> </el-form-item>
<template v-else> <template v-else>
<el-form-item label="验证码" prop="captchaCode"> <el-form-item :label="translate('login.verification_code_bb015c')" prop="captchaCode">
<el-input v-model="fields.captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" placeholder="输入图片中的六位数字" /> <el-input v-model="fields.captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" :placeholder="translate('login.enter_the_six_digits_in_bb5543')" />
</el-form-item> </el-form-item>
<div class="rm-captcha"> <div class="rm-captcha">
<el-button :disabled="refreshing || busy" class="rm-captcha-image" aria-label="刷新验证码" @click="refresh"> <el-button :disabled="refreshing || busy" class="rm-captcha-image" :aria-label="translate('login.refresh_verification_code_bc5b83')" @click="refresh">
<img v-if="captcha" :src="captcha.imageDataUrl" alt="登录验证码"><span v-else>{{ refreshing ? '正在加载验证码…' : '点击重新加载验证码' }}</span> <img v-if="captcha" :src="captcha.imageDataUrl" :alt="translate('login.login_verification_code_3060b7')"><span v-else>{{ refreshing ? translate('login.loading_verification_code_8a7493') : translate('login.click_to_reload_the_verification_97c218') }}</span>
</el-button> </el-button>
<el-button text :disabled="refreshing || busy" @click="refresh"> <el-button text :disabled="refreshing || busy" @click="refresh">
换一张 {{ translate('login.refresh_code_fa28ea') }}
</el-button> </el-button>
</div> </div>
<p v-if="captchaError" class="rm-auth-error" role="alert"> <p v-if="captchaError" class="rm-auth-error" role="alert">
@@ -112,7 +121,7 @@ async function submit() {
{{ error }} {{ error }}
</p> </p>
<el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)"> <el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)">
{{ isSetup ? '完成设置' : '登录' }} {{ isSetup ? translate('login.finish_setup_fda1eb') : translate('common.sign_in_1e2df9') }}
</el-button> </el-button>
</el-form> </el-form>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
{
"enter_your_password_728a7b": "Enter your password",
"password_must_contain_at_least_8c1f11": "Password must contain at least 8 characters",
"passwords_do_not_match_588077": "Passwords do not match",
"enter_the_six_digit_verification_d746b8": "Enter the six-digit verification code",
"invalid_login_response_b83520": "Invalid login response",
"welcome_set_a_password_to_cb8457": "Welcome! Set a password to get started.",
"sign_in_to_your_markdown_4bb381": "Sign in to your Markdown workspace",
"password_a621ab": "Password",
"confirm_password_81b17b": "Confirm password",
"verification_code_bb015c": "Verification code",
"enter_the_six_digits_in_bb5543": "Enter the six digits in the image",
"refresh_verification_code_bc5b83": "Refresh verification code",
"login_verification_code_3060b7": "Login verification code",
"loading_verification_code_8a7493": "Loading verification code…",
"click_to_reload_the_verification_97c218": "Click to reload the verification code",
"refresh_code_fa28ea": "Refresh code",
"finish_setup_fda1eb": "Finish setup"
}
+19
View File
@@ -0,0 +1,19 @@
{
"enter_your_password_728a7b": "请输入密码",
"password_must_contain_at_least_8c1f11": "密码至少 8 位",
"passwords_do_not_match_588077": "两次输入的密码不一致",
"enter_the_six_digit_verification_d746b8": "请输入六位数字验证码",
"invalid_login_response_b83520": "登录响应格式错误",
"welcome_set_a_password_to_cb8457": "首次使用,请设置登录密码。",
"sign_in_to_your_markdown_4bb381": "登录你的 Markdown 知识库",
"password_a621ab": "密码",
"confirm_password_81b17b": "确认密码",
"verification_code_bb015c": "验证码",
"enter_the_six_digits_in_bb5543": "输入图片中的六位数字",
"refresh_verification_code_bc5b83": "刷新验证码",
"login_verification_code_3060b7": "登录验证码",
"loading_verification_code_8a7493": "正在加载验证码…",
"click_to_reload_the_verification_97c218": "点击重新加载验证码",
"refresh_code_fa28ea": "换一张",
"finish_setup_fda1eb": "完成设置"
}
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { changePasswordApi } from '@@/apis/auth' import { changePasswordApi } from '@@/apis/auth'
import Icon from '@@/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
@@ -14,9 +16,13 @@ const user = useUserStore()
const form = ref<FormInstance>() const form = ref<FormInstance>()
const fields = reactive({ current: '', next: '', confirm: '' }) const fields = reactive({ current: '', next: '', confirm: '' })
const rules: FormRules<typeof fields> = { const rules: FormRules<typeof fields> = {
current: [{ required: true, message: '请输入当前密码', trigger: 'blur' }], current: [{ required: true, get message() {
next: [{ required: true, min: 8, message: '新密码至少 8 位', trigger: 'blur' }], return translate('settings.enter_your_current_password_f1790d')
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.next ? new Error('两次输入的新密码不一致') : undefined), trigger: 'blur' }], }, trigger: 'blur' }],
next: [{ required: true, min: 8, get message() {
return translate('settings.new_password_must_contain_at_2f1f54')
}, trigger: 'blur' }],
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.next ? new Error(translate('settings.new_passwords_do_not_match_c5575a')) : undefined), trigger: 'blur' }],
} }
const error = ref('') const error = ref('')
const busy = ref(false) const busy = ref(false)
@@ -30,7 +36,7 @@ async function submit() {
await changePasswordApi({ current: fields.current, next: fields.next }) await changePasswordApi({ current: fields.current, next: fields.next })
Object.assign(fields, { current: '', next: '', confirm: '' }) Object.assign(fields, { current: '', next: '', confirm: '' })
form.value!.clearValidate() form.value!.clearValidate()
toast('登录密码已修改,其他设备需要重新登录') toast(translate('settings.password_changed_other_devices_must_04f0de'))
} catch (cause) { } catch (cause) {
if (!isCancel(cause)) error.value = cause instanceof Error ? cause.message : String(cause) if (!isCancel(cause)) error.value = cause instanceof Error ? cause.message : String(cause)
} finally { } finally {
@@ -42,37 +48,37 @@ async function submit() {
<template> <template>
<section id="rm-setting-password" tabindex="-1"> <section id="rm-setting-password" tabindex="-1">
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
修改登录密码 {{ translate('settings.change_password_81ecab') }}
</h3> </h3>
<p class="rm-share-note"> <p class="rm-share-note">
修改后其他设备需要重新登录。 {{ translate('settings.other_devices_must_sign_in_2c516e') }}
</p> </p>
<el-form ref="form" class="rm-account-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="submit"> <el-form ref="form" class="rm-account-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="submit">
<el-form-item label="当前密码" prop="current"> <el-form-item :label="translate('settings.current_password_a114cf')" prop="current">
<el-input v-model="fields.current" type="password" autocomplete="current-password" show-password /> <el-input v-model="fields.current" type="password" autocomplete="current-password" show-password />
</el-form-item> </el-form-item>
<el-form-item label="新密码" prop="next"> <el-form-item :label="translate('settings.new_password_515e9c')" prop="next">
<el-input v-model="fields.next" type="password" autocomplete="new-password" show-password /> <el-input v-model="fields.next" type="password" autocomplete="new-password" show-password />
</el-form-item> </el-form-item>
<el-form-item label="确认新密码" prop="confirm"> <el-form-item :label="translate('settings.confirm_new_password_6fde05')" prop="confirm">
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password /> <el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
</el-form-item> </el-form-item>
<p v-if="error" class="rm-auth-error" role="alert"> <p v-if="error" class="rm-auth-error" role="alert">
{{ error }} {{ error }}
</p> </p>
<el-button native-type="submit" type="primary" :loading="busy"> <el-button native-type="submit" type="primary" :loading="busy">
修改密码 {{ translate('settings.change_password_08d008') }}
</el-button> </el-button>
</el-form> </el-form>
</section> </section>
<section id="rm-setting-tokens" tabindex="-1"> <section id="rm-setting-tokens" tabindex="-1">
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
API 令牌 {{ translate('common.api_tokens_79db53') }}
</h3><TokensPanel /> </h3><TokensPanel />
</section> </section>
<SettingRow id="logout"> <SettingRow id="logout">
<el-button @click="$emit('logout')"> <el-button @click="$emit('logout')">
<Icon name="logout" />退出 <Icon name="logout" />{{ translate('common.sign_out_498e1d') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
</template> </template>
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Theme } from '@@/apis/themes/type' import type { Theme } from '@@/apis/themes/type'
import type { CssSnippet } from '@@/appearance/appearance'
import type { CssSnippet } from '@@/appearance/appearance'
import type { Settings } from '@/pinia/stores/settings' import type { Settings } from '@/pinia/stores/settings'
import { getThemesApi } from '@@/apis/themes' import { getThemesApi } from '@@/apis/themes'
import { appearancePaused, fixedThemeMode } from '@@/appearance/appearance' import { appearancePaused, fixedThemeMode } from '@@/appearance/appearance'
import { DISPLAY_DEFAULTS } from '@@/appearance/typography' import { DISPLAY_DEFAULTS } from '@@/appearance/typography'
import { t as translate } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm' import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
@@ -18,7 +20,9 @@ import SettingRow from './SettingRow.vue'
const { loadSettings, reloadTheme, settings, updateSettings } = useSettingsStore(pinia) const { loadSettings, reloadTheme, settings, updateSettings } = useSettingsStore(pinia)
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }]) const themes = ref<Theme[]>([{ id: 'default', get name() {
return translate('common.default_844b8c')
}, version: '1' }])
const error = ref('') const error = ref('')
const busy = ref(false) const busy = ref(false)
const editing = ref<string | null>(null) const editing = ref<string | null>(null)
@@ -26,13 +30,17 @@ const draft = ref({ name: '', css: '' })
const missingTheme = computed(() => !themes.value.some(theme => theme.id === settings.theme)) const missingTheme = computed(() => !themes.value.some(theme => theme.id === settings.theme))
const fixedMode = computed(() => fixedThemeMode(settings.theme)) const fixedMode = computed(() => fixedThemeMode(settings.theme))
function themeName(theme: Theme) {
return theme.id === 'default' ? translate('common.default_844b8c') : theme.id === 'white' || theme.id === 'black' ? translate(`settings.theme_${theme.id}`) : theme.name
}
async function refreshThemes() { async function refreshThemes() {
error.value = '' error.value = ''
try { try {
themes.value = (await getThemesApi()).data.themes themes.value = (await getThemesApi()).data.themes
await reloadTheme() await reloadTheme()
} catch (err) { } catch (err) {
error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络' error.value = err instanceof ApiError ? err.message : translate('settings.could_not_load_themes_check_8e9224')
} }
} }
onMounted(refreshThemes) onMounted(refreshThemes)
@@ -47,9 +55,9 @@ async function change(patch: Partial<Settings>): Promise<boolean> {
if (err instanceof SettingsAppearanceError) { if (err instanceof SettingsAppearanceError) {
toast(err.message); return true toast(err.message); return true
} }
toast(`保存外观失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`) toast(translate('settings.could_not_save_appearance_70bfd0', { p0: err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064') }))
void loadSettings().catch((err) => { void loadSettings().catch((err) => {
if (!isCancel(err)) toast(`重新读取外观失败:${err instanceof ApiError ? err.message : '网络错误'}`) if (!isCancel(err)) toast(translate('settings.could_not_reload_appearance_acc776', { p0: err instanceof ApiError ? err.message : translate('common.network_error') }))
}) })
return false return false
} finally { } finally {
@@ -84,7 +92,7 @@ async function remove(snippet: CssSnippet) {
if (busy.value) return if (busy.value) return
busy.value = true busy.value = true
try { try {
if (!await confirmAction(`删除 CSS 片段“${snippet.name}”?`)) return if (!await confirmAction(translate('settings.delete_css_snippet_bb7d52', { p0: snippet.name }))) return
if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) { if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) {
if (editing.value === snippet.id) editing.value = null if (editing.value === snippet.id) editing.value = null
} }
@@ -97,37 +105,37 @@ async function reset() {
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS, cssSnippets: settings.cssSnippets.map(s => ({ ...s, enabled: false })) })) { if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS, cssSnippets: settings.cssSnippets.map(s => ({ ...s, enabled: false })) })) {
return return
} }
toast('已恢复默认外观与排版,CSS 片段已停用并保留') toast(translate('settings.default_appearance_and_typography_restored_85037c'))
} }
</script> </script>
<template> <template>
<p v-if="appearancePaused" class="rm-appearance-note"> <p v-if="appearancePaused" class="rm-appearance-note">
已临时停用主题和 CSS。可恢复默认外观后重新打开页面。 {{ translate('settings.themes_and_css_are_temporarily_0c0bf0') }}
</p> </p>
<SettingRow id="theme-select" control="rm-theme-select"> <SettingRow id="theme-select" control="rm-theme-select">
<el-select id="rm-theme-select" :model-value="settings.theme" :disabled="busy" @change="change({ theme: $event })"> <el-select id="rm-theme-select" :model-value="settings.theme" :disabled="busy" @change="change({ theme: $event })">
<el-option v-for="theme in themes" :key="theme.id" :value="theme.id" :label="theme.name" /> <el-option v-for="theme in themes" :key="theme.id" :value="theme.id" :label="themeName(theme)" />
<el-option v-if="missingTheme" :value="settings.theme" :label="`${settings.theme}(不可用)`" /> <el-option v-if="missingTheme" :value="settings.theme" :label="translate('settings.unavailable_6145a5', { p0: settings.theme })" />
</el-select> </el-select>
<el-button class="rm-btn" :disabled="busy" @click="refreshThemes"> <el-button class="rm-btn" :disabled="busy" @click="refreshThemes">
刷新列表 {{ translate('settings.refresh_list_e28be7') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="color-mode"> <SettingRow id="color-mode">
<el-segmented :model-value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" :options="[{ value: 'light', label: '浅色' }, { value: 'dark', label: '深色' }, { value: 'system', label: '跟随系统' }]" aria-label="明暗模式" @change="change({ colorMode: $event as Settings['colorMode'] })" /> <el-segmented :model-value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" :options="[{ value: 'light', get label() { return translate('settings.light_aa0819') } }, { value: 'dark', get label() { return translate('settings.dark_a6b75d') } }, { value: 'system', get label() { return translate('settings.system_217cfe') } }]" :aria-label="translate('settings.color_mode_65260b')" @change="change({ colorMode: $event as Settings['colorMode'] })" />
</SettingRow> </SettingRow>
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert"> <p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">
{{ error }} {{ error }}
</p> </p>
<p v-if="fixedMode" class="rm-appearance-note"> <p v-if="fixedMode" class="rm-appearance-note">
白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。 {{ translate('settings.the_light_and_dark_themes_b3b0c9') }}
</p> </p>
<details class="rm-settings-details rm-appearance-advanced"> <details class="rm-settings-details rm-appearance-advanced">
<summary>自定义 CSS 与恢复</summary> <summary>{{ translate('settings.custom_css_and_recovery_cc220b') }}</summary>
<SettingRow id="css-snippets"> <SettingRow id="css-snippets">
<el-button class="rm-btn" :disabled="busy" @click="edit()"> <el-button class="rm-btn" :disabled="busy" @click="edit()">
新建片段 {{ translate('settings.new_snippet_b2808d') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row"> <div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
@@ -135,30 +143,30 @@ async function reset() {
{{ snippet.name }} {{ snippet.name }}
</el-checkbox> </el-checkbox>
<el-button class="rm-btn" :disabled="busy" @click="edit(snippet)"> <el-button class="rm-btn" :disabled="busy" @click="edit(snippet)">
编辑 {{ translate('common.edit_051836') }}
</el-button> </el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)"> <el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">
删除 {{ translate('common.delete_2f9daa') }}
</el-button> </el-button>
</div> </div>
<el-form v-if="editing !== null" class="rm-snippet-form" label-position="top" @submit.prevent="saveSnippet"> <el-form v-if="editing !== null" class="rm-snippet-form" label-position="top" @submit.prevent="saveSnippet">
<label>片段名称<el-input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label> <label>{{ translate('settings.snippet_name_5679e2') }}<el-input v-model="draft.name" :aria-label="translate('settings.snippet_name_5679e2')" maxlength="100" required /></label>
<label>CSS<el-input v-model="draft.css" type="textarea" aria-label="CSS" :rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label> <label>CSS<el-input v-model="draft.css" type="textarea" aria-label="CSS" :rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div> <div>
<el-button class="rm-btn" :disabled="busy" native-type="submit"> <el-button class="rm-btn" :disabled="busy" native-type="submit">
保存并应用 {{ translate('settings.save_and_apply_a7ec01') }}
</el-button> <el-button class="rm-btn" native-type="button" @click="editing = null"> </el-button> <el-button class="rm-btn" native-type="button" @click="editing = null">
取消 {{ translate('common.cancel_2cd0f3') }}
</el-button> </el-button>
</div> </div>
</el-form> </el-form>
<SettingRow id="reset-appearance"> <SettingRow id="reset-appearance">
<el-button class="rm-btn" :disabled="busy" @click="reset"> <el-button class="rm-btn" :disabled="busy" @click="reset">
恢复默认外观与排版 {{ translate('settings.reset_appearance_and_typography_c73c09') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<p class="rm-appearance-note"> <p class="rm-appearance-note">
若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。 {{ translate('settings.if_custom_styles_make_the_aed659') }} <code>?reset-appearance</code>{{ translate('settings.to_the_url_to_temporarily_b75ce2') }}
</p> </p>
</details> </details>
</template> </template>
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import FontPicker from '@@/components/FontPicker/index.vue' import FontPicker from '@@/components/FontPicker/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, useSettingsStore } from '@/pinia/stores/settings' import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, useSettingsStore } from '@/pinia/stores/settings'
import { useSettingsChange } from '../composables/use-settings-change' import { useSettingsChange } from '../composables/use-settings-change'
@@ -21,7 +23,7 @@ function changeFont(value: string) {
<template> <template>
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
正文与代码 {{ translate('settings.body_text_and_code_095337') }}
</h3> </h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"> <SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })">
<FontPicker id="rm-text-font" setting="textFont" /> <FontPicker id="rm-text-font" setting="textFont" />
@@ -37,18 +39,18 @@ function changeFont(value: string) {
</el-input> </el-input>
</SettingRow> </SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"> <SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })">
<el-segmented :model-value="settings.contentWidth" :options="CONTENT_WIDTHS" aria-label="正文宽度" @change="change({ contentWidth: Number($event) })" /> <el-segmented :model-value="settings.contentWidth" :options="CONTENT_WIDTHS" :aria-label="translate('settings.content_width_e92272')" @change="change({ contentWidth: Number($event) })" />
</SettingRow> </SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"> <SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })">
<el-slider id="rm-line-height" :min="1.2" :max="2.5" :step="0.05" :model-value="settings.lineHeight" @change="change({ lineHeight: Number($event) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output> <el-slider id="rm-line-height" :min="1.2" :max="2.5" :step="0.05" :model-value="settings.lineHeight" @change="change({ lineHeight: Number($event) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output>
</SettingRow> </SettingRow>
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1"> <details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1">
<summary>排版预览</summary><article class="rm-typography-preview"> <summary>{{ translate('settings.typography_preview_31ac73') }}</summary><article class="rm-typography-preview">
<h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code> <h2>{{ translate('settings.give_your_words_room_to_f3720b') }}</h2><p>{{ translate('settings.connect_your_ideas_with_simple_d14ce8') }}</p><p>{{ translate('settings.real_content_natural_typography_and_9322bc') }}</p><code>const idea = 'Hello, RawMark'</code>
</article> </article>
</details> </details>
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
编辑行为 {{ translate('settings.editing_behavior_870d78') }}
</h3> </h3>
<SettingRow id="selection-toolbar" control="rm-selection-toolbar"> <SettingRow id="selection-toolbar" control="rm-selection-toolbar">
<el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" /> <el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" />
@@ -58,7 +60,7 @@ function changeFont(value: string) {
</SettingRow> </SettingRow>
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })"> <SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })">
<el-select id="rm-tab-size" :model-value="settings.tabSize" @change="change({ tabSize: $event })"> <el-select id="rm-tab-size" :model-value="settings.tabSize" @change="change({ tabSize: $event })">
<el-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="`${n} 列`" /> <el-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="translate('settings.columns_dc30c8', { p0: n })" />
</el-select> </el-select>
</SettingRow> </SettingRow>
</template> </template>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { UI_FONT_SIZE } from '@@/appearance/typography' import { UI_FONT_SIZE } from '@@/appearance/typography'
import FontPicker from '@@/components/FontPicker/index.vue' import FontPicker from '@@/components/FontPicker/index.vue'
import LocaleSelect from '@@/components/LocaleSelect/index.vue'
import { DEFAULTS, useSettingsStore } from '@/pinia/stores/settings' import { DEFAULTS, useSettingsStore } from '@/pinia/stores/settings'
import { useSettingsChange } from '../composables/use-settings-change' import { useSettingsChange } from '../composables/use-settings-change'
import AppearanceSettings from './AppearanceSettings.vue' import AppearanceSettings from './AppearanceSettings.vue'
@@ -11,6 +12,9 @@ const change = useSettingsChange()
</script> </script>
<template> <template>
<SettingRow id="locale" control="rm-locale">
<LocaleSelect id="rm-locale" @change="change({ locale: $event })" />
</SettingRow>
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })"> <SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })">
<FontPicker id="rm-ui-font" setting="uiFont" /> <FontPicker id="rm-ui-font" setting="uiFont" />
</SettingRow> </SettingRow>
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BackupKind, MaintenanceStatus } from '@@/apis/maintenance/type' import type { BackupKind, MaintenanceStatus } from '@@/apis/maintenance/type'
import type { AxiosHeaders, AxiosResponse } from 'axios' import type { AxiosHeaders, AxiosResponse } from 'axios'
import { getAuthStateApi } from '@@/apis/auth' import { getAuthStateApi } from '@@/apis/auth'
import { cancelBackupUploadApi, clearTempApi, exportBackupApi, exportLogsApi, exportNotesApi, getMaintenanceStatusApi, importSettingsApi, maintainIndexApi, resetSettingsApi, restoreBackupApi, startBackupUploadApi, uploadBackupChunkApi, validateBackupApi } from '@@/apis/maintenance' import { cancelBackupUploadApi, clearTempApi, exportBackupApi, exportLogsApi, exportNotesApi, getMaintenanceStatusApi, importSettingsApi, maintainIndexApi, resetSettingsApi, restoreBackupApi, startBackupUploadApi, uploadBackupChunkApi, validateBackupApi } from '@@/apis/maintenance'
import { locale, t as translate } from '@@/i18n/runtime'
import { refreshPlugins } from '@@/plugins/host' import { refreshPlugins } from '@@/plugins/host'
import { confirmAction } from '@@/utils/confirm' import { confirmAction } from '@@/utils/confirm'
@@ -38,7 +40,7 @@ onMounted(async () => {
try { try {
const status = (await getMaintenanceStatusApi()).data; if (mounted) last.value = status const status = (await getMaintenanceStatusApi()).data; if (mounted) last.value = status
} catch (cause) { } catch (cause) {
if (mounted) error.value = `读取维护状态失败:${String(cause)}` if (mounted) error.value = translate('settings.could_not_load_maintenance_status_51d391', { p0: String(cause) })
} }
}) })
async function prepare() { async function prepare() {
@@ -75,16 +77,16 @@ async function download(response: AxiosResponse<Blob, unknown, AxiosHeaders>, fa
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = Object.assign(document.createElement('a'), { href: url, download: name }) const link = Object.assign(document.createElement('a'), { href: url, download: name })
link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
message.value = '导出文件已生成' message.value = translate('settings.export_file_generated_9d8f6c')
} }
async function exportPackage(kind: BackupKind) { async function exportPackage(kind: BackupKind) {
await perform('正在导出…', async () => download(await exportBackupApi(kind), `rawmark-${kind}.zip`)) await perform(translate('settings.exporting_62731d'), async () => download(await exportBackupApi(kind), `rawmark-${kind}.zip`))
} }
async function exportMarkdown() { async function exportMarkdown() {
await perform('正在导出笔记…', async () => download(await exportNotesApi(), 'rawmark-notes.zip')) await perform(translate('settings.exporting_notes_015ed1'), async () => download(await exportNotesApi(), 'rawmark-notes.zip'))
} }
async function exportSystemLogs() { async function exportSystemLogs() {
await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip')) await perform(translate('settings.exporting_logs_2a89f7'), async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
} }
async function syncPreferences(completed: string, version: number) { async function syncPreferences(completed: string, version: number) {
try { try {
@@ -98,7 +100,7 @@ async function syncPreferences(completed: string, version: number) {
} catch (cause) { } catch (cause) {
user.assertSession(version) user.assertSession(version)
if (isCancel(cause)) throw cause if (isCancel(cause)) throw cause
throw new Error(`${completed},但同步失败:${cause instanceof Error ? cause.message : String(cause)}`, { cause }) throw new Error(translate('settings.but_synchronization_failed_54aebd', { p0: completed, p1: cause instanceof Error ? cause.message : String(cause) }), { cause })
} }
message.value = completed message.value = completed
} }
@@ -107,46 +109,46 @@ async function reset() {
busy.value = true busy.value = true
let confirmed = false let confirmed = false
try { try {
confirmed = await confirmAction('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。') confirmed = await confirmAction(translate('settings.reset_all_rawmark_preferences_shortcuts_9ab27e'))
} finally { } finally {
busy.value = false busy.value = false
} }
if (!confirmed) return if (!confirmed) return
await perform('正在重置设置…', async (version) => { await perform(translate('settings.resetting_settings_39808b'), async (version) => {
await resetSettingsApi(); await syncPreferences('设置已重置,原设置已备份', version) await resetSettingsApi(); await syncPreferences(translate('settings.settings_reset_previous_settings_backed_f4a4fa'), version)
}) })
} }
async function restore(kind: BackupKind, event: Event) { async function restore(kind: BackupKind, event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0]; input.value = '' const file = input.files?.[0]; input.value = ''
if (!file) return if (!file) return
await perform('正在上传…', async () => { await perform(translate('settings.uploading_6148b3'), async () => {
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间') if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error(translate('settings.the_compressed_backup_must_be_cdce88'))
const version = user.getSessionVersion() const version = user.getSessionVersion()
const { id } = (await startBackupUploadApi({ kind, size: file.size })).data const { id } = (await startBackupUploadApi({ kind, size: file.size })).data
let submitted = false let submitted = false
try { try {
let offset = 0 let offset = 0
while (offset < file.size) { while (offset < file.size) {
if (!mounted) throw new Error('已取消上传') if (!mounted) throw new Error(translate('settings.upload_canceled_fb2c92'))
const chunk = file.slice(offset, offset + 8 * 1024 ** 2) const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
const result = (await uploadBackupChunkApi({ id, offset, body: chunk })).data const result = (await uploadBackupChunkApi({ id, offset, body: chunk })).data
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致') if (result.offset !== offset + chunk.size) throw new Error(translate('settings.the_server_returned_inconsistent_upload_dd61f8'))
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%` offset = result.offset; progress.value = translate('settings.uploading_38466c', { p0: Math.round(offset / file.size * 100) })
} }
progress.value = '正在校验备份…' progress.value = translate('settings.validating_backup_7a1f32')
const summary = (await validateBackupApi(id)).data const summary = (await validateBackupApi(id)).data
if (!mounted) throw new Error('已取消导入') if (!mounted) throw new Error(translate('settings.import_canceled_08d7a9'))
const description = kind === 'full' const description = kind === 'full'
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。' ? translate('settings.all_instance_data_will_be_4b1eb6')
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。' : translate('settings.all_preferences_and_plugin_configuration_11b0e4')
if (!await confirmAction(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return if (!await confirmAction(translate('settings.backup_date_entries_expanded_size_b6755e', { p0: new Date(summary.createdAt).toLocaleString(locale.value), p1: summary.entries, p2: (summary.size / 1024 ** 2).toFixed(2), p3: description }))) return
await prepare() await prepare()
user.assertSession(version) user.assertSession(version)
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…' progress.value = kind === 'full' ? translate('settings.restoring_instance_keep_this_page_405c69') : translate('settings.importing_settings_078500')
if (kind === 'settings') { if (kind === 'settings') {
await importSettingsApi(id); submitted = true await importSettingsApi(id); submitted = true
await syncPreferences('设置与资源已导入,原设置已备份', version) await syncPreferences(translate('settings.settings_and_resources_imported_previous_f96d82'), version)
} else { } else {
await restoreBackupApi(id); submitted = true await restoreBackupApi(id); submitted = true
user.invalidate(version) user.invalidate(version)
@@ -160,29 +162,29 @@ async function restore(kind: BackupKind, event: Event) {
} }
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ } } catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
} }
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果') throw new Error(translate('settings.restore_request_submitted_the_service_b1d2c2'))
} }
} finally { } finally {
if (!submitted && version === user.getSessionVersion()) { if (!submitted && version === user.getSessionVersion()) {
try { try {
await cancelBackupUploadApi(id) await cancelBackupUploadApi(id)
} catch (cause) { } catch (cause) {
if (mounted) error.value = `清理上传暂存失败:${String(cause)}` if (mounted) error.value = translate('settings.could_not_clean_up_the_ca0ac3', { p0: String(cause) })
} }
} }
} }
}) })
} }
async function index(operation: 'optimize' | 'rebuild') { async function index(operation: 'optimize' | 'rebuild') {
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => { await perform(operation === 'optimize' ? translate('settings.optimizing_index_0aabbf') : translate('settings.rebuilding_index_ae8fea'), async () => {
await maintainIndexApi(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建' await maintainIndexApi(operation); message.value = operation === 'optimize' ? translate('settings.index_optimized_dfe862') : translate('settings.index_rebuilt_d1763b')
}) })
} }
async function clearTemp() { async function clearTemp() {
await perform('正在清理临时文件…', async () => { await perform(translate('settings.cleaning_temporary_files_a62be6'), async () => {
const result = (await clearTempApi()).data const result = (await clearTempApi()).data
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB` message.value = translate('settings.removed_files_freed_mib_027d71', { p0: result.files, p1: (result.bytes / 1024 ** 2).toFixed(2) })
if (result.failed.length) error.value = `部分清理失败:\n${result.failed.join('\n')}` if (result.failed.length) error.value = translate('settings.some_files_could_not_be_628a24', { p0: result.failed.join('\n') })
}) })
} }
function importNotes() { function importNotes() {
@@ -193,55 +195,55 @@ function importNotes() {
<template> <template>
<div class="rm-maintenance" :aria-busy="busy"> <div class="rm-maintenance" :aria-busy="busy">
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
备份与迁移 {{ translate('settings.backup_and_migration_97dce0') }}
</h3> </h3>
<SettingRow id="backup-full"> <SettingRow id="backup-full">
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('full')"> <el-button class="rm-btn" :disabled="busy" @click="exportPackage('full')">
导出 {{ translate('settings.export_764204') }}
</el-button><el-button class="rm-btn" :disabled="busy" @click="fullInput!.click()"> </el-button><el-button class="rm-btn" :disabled="busy" @click="fullInput!.click()">
恢复 {{ translate('common.restore_e0534b') }}
</el-button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)"> </el-button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)">
</SettingRow> </SettingRow>
<SettingRow id="backup-notes"> <SettingRow id="backup-notes">
<el-button class="rm-btn" :disabled="busy" @click="exportMarkdown"> <el-button class="rm-btn" :disabled="busy" @click="exportMarkdown">
导出 {{ translate('settings.export_764204') }}
</el-button><el-button class="rm-btn" :disabled="busy" @click="importNotes"> </el-button><el-button class="rm-btn" :disabled="busy" @click="importNotes">
导入 {{ translate('common.import_576d81') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="backup-settings"> <SettingRow id="backup-settings">
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('settings')"> <el-button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">
导出 {{ translate('settings.export_764204') }}
</el-button><el-button class="rm-btn" :disabled="busy" @click="settingsInput!.click()"> </el-button><el-button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">
导入 {{ translate('common.import_576d81') }}
</el-button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)"> </el-button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)">
</SettingRow> </SettingRow>
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
维护工具 {{ translate('settings.maintenance_tools_051093') }}
</h3> </h3>
<SettingRow id="index-optimize"> <SettingRow id="index-optimize">
<el-button class="rm-btn" :disabled="busy" @click="index('optimize')"> <el-button class="rm-btn" :disabled="busy" @click="index('optimize')">
优化 {{ translate('settings.optimize_e1d83f') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="index-rebuild"> <SettingRow id="index-rebuild">
<el-button class="rm-btn" :disabled="busy" @click="index('rebuild')"> <el-button class="rm-btn" :disabled="busy" @click="index('rebuild')">
重建 {{ translate('settings.rebuild_4b8d9d') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="temp-clear"> <SettingRow id="temp-clear">
<el-button class="rm-btn" :disabled="busy" @click="clearTemp"> <el-button class="rm-btn" :disabled="busy" @click="clearTemp">
清理 {{ translate('settings.clean_up_9d2998') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="settings-reset"> <SettingRow id="settings-reset">
<el-button class="rm-btn" :disabled="busy" @click="reset"> <el-button class="rm-btn" :disabled="busy" @click="reset">
重置 {{ translate('settings.reset_cb5d68') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="logs-export"> <SettingRow id="logs-export">
<el-button class="rm-btn" :disabled="busy" @click="exportSystemLogs"> <el-button class="rm-btn" :disabled="busy" @click="exportSystemLogs">
导出 {{ translate('settings.export_764204') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<p v-if="busy" role="status" aria-live="polite"> <p v-if="busy" role="status" aria-live="polite">
@@ -254,10 +256,10 @@ function importNotes() {
{{ error }} {{ error }}
</p> </p>
<p v-if="last?.error" class="rm-auth-error" role="alert"> <p v-if="last?.error" class="rm-auth-error" role="alert">
上次维护失败:{{ last.error }} {{ translate('settings.last_maintenance_failed_283226') }}{{ last.error }}
</p> </p>
<p v-if="last?.backup" class="rm-share-note"> <p v-if="last?.backup" class="rm-share-note">
操作前备份:backups/{{ last.backup }} {{ translate('settings.pre_operation_backup_backups_b966a1') }}{{ last.backup }}
</p> </p>
</div> </div>
</template> </template>
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InstalledPlugin } from '@@/apis/plugins/type' import type { InstalledPlugin } from '@@/apis/plugins/type'
import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
import PluginMount from '@@/components/PluginMount/index.vue' import PluginMount from '@@/components/PluginMount/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { pluginState, refreshPlugins, retryPlugin } from '@@/plugins/host' import { pluginState, refreshPlugins, retryPlugin } from '@@/plugins/host'
import { pluginsPaused, settingTabs } from '@@/plugins/registry' import { pluginsPaused, settingTabs } from '@@/plugins/registry'
import { confirmAction } from '@@/utils/confirm' import { confirmAction } from '@@/utils/confirm'
@@ -15,7 +17,17 @@ const error = ref('')
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
const selected = ref('') const selected = ref('')
const currentTab = computed(() => settingTabs.get(selected.value)) const currentTab = computed(() => settingTabs.get(selected.value))
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' } const labels: Record<string, string> = { get loading() {
return translate('settings.loading_d04fcb')
}, get active() {
return translate('settings.running_1f0eb9')
}, get error() {
return translate('settings.disabled_after_an_error_e8b7b5')
}, get disabled() {
return translate('settings.disabled_a8c369')
}, get paused() {
return translate('settings.temporarily_skipped_9f45ce')
} }
async function perform(operation: () => Promise<unknown> = refreshPlugins, confirmation?: string) { async function perform(operation: () => Promise<unknown> = refreshPlugins, confirmation?: string) {
if (busy.value) return if (busy.value) return
@@ -39,13 +51,13 @@ async function install(event: Event) {
if (!file) return if (!file) return
await perform(async () => { await perform(async () => {
await installPluginApi(file); await refreshPlugins() await installPluginApi(file); await refreshPlugins()
}, '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?') }, translate('settings.plugins_have_full_access_to_7743b6'))
} }
async function remove(plugin: InstalledPlugin) { async function remove(plugin: InstalledPlugin) {
await perform(async () => { await perform(async () => {
await uninstallPluginApi(plugin.id); await refreshPlugins() await uninstallPluginApi(plugin.id); await refreshPlugins()
}, `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`) }, translate('settings.uninstall_plugin_the_plugin_will_6aa869', { p0: plugin.name }))
} }
function toggle(plugin: InstalledPlugin) { function toggle(plugin: InstalledPlugin) {
@@ -58,60 +70,60 @@ onMounted(() => perform())
<template> <template>
<p v-if="pluginsPaused" class="rm-appearance-note"> <p v-if="pluginsPaused" class="rm-appearance-note">
已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。 {{ translate('settings.external_plugins_are_temporarily_skipped_f85ce0') }}
</p> </p>
<div class="rm-setting"> <div class="rm-setting">
<span class="rm-setting-label">已安装插件</span> <span class="rm-setting-label">{{ translate('settings.installed_plugins_e476c3') }}</span>
<el-button class="rm-btn" :disabled="busy" @click="perform()"> <el-button class="rm-btn" :disabled="busy" @click="perform()">
刷新列表 {{ translate('settings.refresh_list_e28be7') }}
</el-button> </el-button>
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()"> <el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()">
安装 ZIP {{ translate('settings.install_zip_4f1723') }}
</el-button> </el-button>
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install"> <input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install">
</div> </div>
<p class="rm-appearance-note"> <p class="rm-appearance-note">
插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。 {{ translate('settings.plugins_have_full_access_to_0cee40') }}
</p> </p>
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error"> <p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">
{{ error || pluginState.error }} {{ error || pluginState.error }}
</p> </p>
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note"> <p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">
尚未安装插件。 {{ translate('settings.no_plugins_installed_eaaa06') }}
</p> </p>
<el-table :data="pluginState.installed" row-key="id" empty-text="尚未安装插件" :aria-busy="busy"> <el-table :data="pluginState.installed" row-key="id" :empty-text="translate('settings.no_plugins_installed_9d17e5')" :aria-busy="busy">
<!-- @vue-generic {InstalledPlugin} --> <!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="插件" min-width="170"> <el-table-column :label="translate('settings.plugins_e806fb')" min-width="170">
<template #default="{ row: plugin }"> <template #default="{ row: plugin }">
<strong>{{ plugin.name }}</strong><small class="rm-plugin-version">{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small><p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error"> <strong>{{ plugin.name }}</strong><small class="rm-plugin-version">{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? translate('settings.waiting_to_load_e1cfcb') : translate('settings.disabled_a8c369')) }}</small><p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">
{{ pluginState.status[plugin.id].error }} {{ pluginState.status[plugin.id].error }}
</p> </p>
</template> </template>
</el-table-column> </el-table-column>
<!-- @vue-generic {InstalledPlugin} --> <!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="启用" width="72"> <el-table-column :label="translate('settings.enable_f4f0ea')" width="72">
<template #default="{ row: plugin }"> <template #default="{ row: plugin }">
<el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="`启用 ${plugin.name}`" @change="toggle(plugin)" /> <el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="translate('settings.enable_2a670f', { p0: plugin.name })" @change="toggle(plugin)" />
</template> </template>
</el-table-column> </el-table-column>
<!-- @vue-generic {InstalledPlugin} --> <!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="操作" width="130"> <el-table-column :label="translate('settings.actions_ed31fb')" width="130">
<template #default="{ row: plugin }"> <template #default="{ row: plugin }">
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="perform(() => retryPlugin(plugin.id))"> <el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="perform(() => retryPlugin(plugin.id))">
重试 {{ translate('common.retry_b8784c') }}
</el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)"> </el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)">
卸载 {{ translate('settings.uninstall_06bc14') }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<p class="rm-appearance-note"> <p class="rm-appearance-note">
若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。 {{ translate('settings.if_a_plugin_makes_the_46626f') }} <code>?disable-plugins</code> {{ translate('settings.to_the_app_url_to_db1658') }}
</p> </p>
<template v-if="settingTabs.size"> <template v-if="settingTabs.size">
<div class="rm-setting"> <div class="rm-setting">
<label for="rm-plugin-tab" class="rm-setting-label">插件设置</label> <label for="rm-plugin-tab" class="rm-setting-label">{{ translate('settings.plugin_settings_b1bc55') }}</label>
<el-select id="rm-plugin-tab" v-model="selected" clearable placeholder="选择插件设置页"> <el-select id="rm-plugin-tab" v-model="selected" clearable :placeholder="translate('settings.choose_a_plugin_settings_page_7171ad')">
<el-option v-for="[id, tab] in settingTabs" :key="id" :value="id" :label="tab.title" /> <el-option v-for="[id, tab] in settingTabs" :key="id" :value="id" :label="tab.title" />
</el-select> </el-select>
</div> </div>
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SettingId } from '../settings-catalog' import type { SettingId } from '../settings-catalog'
import Icon from '@@/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { computed } from 'vue' import { computed } from 'vue'
import { SETTING_ITEMS } from '../settings-catalog' import { SETTING_ITEMS } from '../settings-catalog'
@@ -19,7 +21,7 @@ const item = computed(() => SETTING_ITEMS[props.id])
</div> </div>
<div class="rm-setting-control"> <div class="rm-setting-control">
<slot /> <slot />
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="`重置${item.title}`" :aria-label="`重置${item.title}`" @click="$emit('reset')"> <button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="translate('settings.reset_6b6fa6', { p0: item.title })" :aria-label="translate('settings.reset_6b6fa6', { p0: item.title })" @click="$emit('reset')">
<Icon name="restore" /> <Icon name="restore" />
</button> </button>
</div> </div>
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Command } from '@@/utils/commands' import type { Command } from '@@/utils/commands'
import Icon from '@@/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '@@/utils/commands' import { allCommands, commandKeys, formatKeys, shortcutConflict } from '@@/utils/commands'
import { recordedKeys } from '@@/utils/keys' import { recordedKeys } from '@@/utils/keys'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
@@ -35,8 +37,8 @@ async function save(shortcuts: Record<string, string[]>) {
if (cause instanceof SettingsAppearanceError) { if (cause instanceof SettingsAppearanceError) {
toast(cause.message); return toast(cause.message); return
} }
toast(`保存快捷键失败:${cause instanceof Error ? cause.message : String(cause)}`) toast(translate('settings.could_not_save_shortcuts_d58683', { p0: cause instanceof Error ? cause.message : String(cause) }))
await loadSettings().catch(cause => toast(`重新读取设置失败:${String(cause)}`)) await loadSettings().catch(cause => toast(translate('settings.could_not_reload_settings_744b6a', { p0: String(cause) })))
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -57,7 +59,7 @@ async function capture(event: KeyboardEvent, command: Command) {
if (!key) return if (!key) return
const conflict = shortcutConflict(command.id, [key]) const conflict = shortcutConflict(command.id, [key])
if (conflict) { if (conflict) {
error.value = `与“${conflict.title}”冲突,请换一个快捷键`; return error.value = translate('settings.conflicts_with_choose_a_different_b5848b', { p0: conflict.title }); return
} }
const keys = [...new Set([...commandKeys(command), key])] const keys = [...new Set([...commandKeys(command), key])]
recording.value = null recording.value = null
@@ -67,7 +69,7 @@ async function capture(event: KeyboardEvent, command: Command) {
async function reset(command: Command) { async function reset(command: Command) {
const conflict = shortcutConflict(command.id, command.keys ?? []) const conflict = shortcutConflict(command.id, command.keys ?? [])
if (conflict) { if (conflict) {
error.value = `默认快捷键与“${conflict.title}”冲突,请先调整该命令`; return error.value = translate('settings.the_default_shortcut_conflicts_with_a89537', { p0: conflict.title }); return
} }
const next = { ...settings.shortcuts } const next = { ...settings.shortcuts }
delete next[command.id] delete next[command.id]
@@ -79,18 +81,18 @@ async function reset(command: Command) {
<template> <template>
<div class="rm-shortcut-settings"> <div class="rm-shortcut-settings">
<div class="rm-shortcut-toolbar"> <div class="rm-shortcut-toolbar">
<el-input v-model="query" type="search" clearable aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})"> <el-input v-model="query" type="search" clearable :aria-label="translate('settings.search_shortcuts_49504f')" :placeholder="translate('settings.search_commands_or_shortcuts_890754')" /><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
重置全部快捷键 {{ translate('settings.reset_all_shortcuts_1853c7') }}
</el-button> </el-button>
</div> </div>
<p class="rm-share-note"> <p class="rm-share-note">
点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。 {{ translate('settings.click_add_then_press_a_cf4a60') }}
</p> </p>
<p class="rm-share-note"> <p class="rm-share-note">
浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。 {{ translate('settings.browsers_may_reserve_ctrl_cmd_8d4bc8') }}
</p> </p>
<p class="rm-settings-result-count" role="status"> <p class="rm-settings-result-count" role="status">
{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }} {{ filteredCommands.length }} / {{ commands.length }} {{ translate('settings.commands_6aa9ce') }}{{ filteredCommands.length ? '' : translate('settings.no_matching_shortcuts_7e3955') }}
</p> </p>
<p v-if="error" role="alert" class="rm-auth-error"> <p v-if="error" role="alert" class="rm-auth-error">
{{ error }} {{ error }}
@@ -100,14 +102,14 @@ async function reset(command: Command) {
<div class="rm-shortcut-bindings"> <div class="rm-shortcut-bindings">
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip"> <span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
<kbd>{{ formatKeys(key) }}</kbd> <kbd>{{ formatKeys(key) }}</kbd>
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button> <button class="rm-icon-btn" :disabled="busy" :aria-label="translate('settings.remove_from_6909d6', { p0: command.title, p1: formatKeys(key) })" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button>
</span> </span>
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly aria-label="按下快捷键" placeholder="按下快捷键…" @keydown.prevent.stop="capture($event, command)" @blur="recording = null"> <input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly :aria-label="translate('settings.press_a_shortcut_662b79')" :placeholder="translate('settings.press_a_shortcut_652436')" @keydown.prevent.stop="capture($event, command)" @blur="recording = null">
<el-button v-else class="rm-btn" :disabled="busy" @click="record(command.id)"> <el-button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">
添加 {{ translate('settings.add_7a8a11') }}
</el-button> </el-button>
<el-button class="rm-btn" :disabled="busy" @click="reset(command)"> <el-button class="rm-btn" :disabled="busy" @click="reset(command)">
重置 {{ translate('settings.reset_cb5d68') }}
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -2,6 +2,8 @@
import type { StorageUsage } from '@@/apis/storage/type' import type { StorageUsage } from '@@/apis/storage/type'
import { getStorageApi } from '@@/apis/storage' import { getStorageApi } from '@@/apis/storage'
import { locale, t as translate } from '@@/i18n/runtime'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import MaintenancePanel from './MaintenancePanel.vue' import MaintenancePanel from './MaintenancePanel.vue'
@@ -18,7 +20,19 @@ onBeforeUnmount(() => {
mounted = false mounted = false
}) })
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' } const names: Record<StorageUsage['categories'][number]['id'], string> = { get vault() {
return translate('settings.vault_7e16a8')
}, get databases() {
return translate('settings.databases_263200')
}, get fonts() {
return translate('common.font_078838')
}, get themes() {
return translate('settings.themes_788db1')
}, get plugins() {
return translate('settings.plugins_e806fb')
}, get other() {
return translate('settings.other_d2909f')
} }
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8'] const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
const segments = computed(() => { const segments = computed(() => {
let offset = 0 let offset = 0
@@ -33,9 +47,9 @@ const segments = computed(() => {
function size(value: number): string { function size(value: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0 const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0
return `${(value / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}` return `${(value / 1024 ** unit).toLocaleString(locale.value, { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
} }
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '') const updated = computed(() => usage.value ? new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
async function scan() { async function scan() {
if (busy.value) return if (busy.value) return
busy.value = true busy.value = true
@@ -44,7 +58,7 @@ async function scan() {
const next = (await getStorageApi()).data const next = (await getStorageApi()).data
if (mounted) usage.value = next if (mounted) usage.value = next
} catch (cause) { } catch (cause) {
if (mounted) error.value = `统计失败:${String(cause)}` if (mounted) error.value = translate('settings.could_not_calculate_storage_usage_6db82c', { p0: String(cause) })
} finally { } finally {
if (mounted) busy.value = false if (mounted) busy.value = false
} }
@@ -55,50 +69,50 @@ onMounted(scan)
<template> <template>
<div> <div>
<h3 class="rm-settings-group-title"> <h3 class="rm-settings-group-title">
空间占用 {{ translate('settings.storage_usage_37ac17') }}
</h3> </h3>
<SettingRow id="storage"> <SettingRow id="storage">
<el-button class="rm-btn" :disabled="busy" @click="scan"> <el-button class="rm-btn" :disabled="busy" @click="scan">
{{ busy ? '统计中…' : '刷新' }} {{ busy ? translate('settings.calculating_884dbb') : translate('settings.refresh_aee887') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<p class="rm-share-note"> <p class="rm-share-note">
统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。 {{ translate('settings.includes_application_data_and_optional_95f18a') }}
</p> </p>
<p v-if="error" class="rm-auth-error" role="alert"> <p v-if="error" class="rm-auth-error" role="alert">
{{ error }} {{ error }}
</p> </p>
<div v-if="usage" class="rm-storage" :aria-busy="busy"> <div v-if="usage" class="rm-storage" :aria-busy="busy">
<div class="rm-storage-summary"> <div class="rm-storage-summary">
<svg viewBox="0 0 120 120" role="img" aria-label="各类存储空间占比"> <svg viewBox="0 0 120 120" role="img" :aria-label="translate('settings.storage_usage_by_category_614032')">
<circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" /> <circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" />
<circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" /> <circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" />
</svg> </svg>
<div><span>总占用</span><strong :title="`${usage.totalSize} 字节`">{{ size(usage.totalSize) }}</strong><small>统计时间:{{ updated }}</small></div> <div><span>{{ translate('settings.total_usage_d3d98e') }}</span><strong :title="translate('settings.bytes_8180a4', { p0: usage.totalSize })">{{ size(usage.totalSize) }}</strong><small>{{ translate('settings.calculated_239bdc') }}{{ updated }}</small></div>
</div> </div>
<dl class="rm-storage-list"> <dl class="rm-storage-list">
<template v-for="segment in segments" :key="segment.id"> <template v-for="segment in segments" :key="segment.id">
<div> <div>
<dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`"> <dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="translate('settings.bytes_8180a4', { p0: segment.size })">
{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small> {{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small>
</dd> </dd>
</div> </div>
<template v-if="segment.id === 'fonts'"> <template v-if="segment.id === 'fonts'">
<div class="rm-storage-sub"> <div class="rm-storage-sub">
<dt>内置可选字体</dt><dd>{{ size(usage.builtinFontsSize) }}</dd> <dt>{{ translate('settings.optional_built_in_fonts_05b309') }}</dt><dd>{{ size(usage.builtinFontsSize) }}</dd>
</div> </div>
<div class="rm-storage-sub"> <div class="rm-storage-sub">
<dt>导入字体</dt><dd>{{ size(usage.customFontsSize) }}</dd> <dt>{{ translate('common.import_font_50751a') }}</dt><dd>{{ size(usage.customFontsSize) }}</dd>
</div> </div>
</template> </template>
<template v-if="segment.id === 'vault'"> <template v-if="segment.id === 'vault'">
<div class="rm-storage-sub"> <div class="rm-storage-sub">
<dt>附件</dt><dd :title="`${usage.assetsSize} 字节`"> <dt>{{ translate('settings.attachments_c7225d') }}</dt><dd :title="translate('settings.bytes_8180a4', { p0: usage.assetsSize })">
{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small> {{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small>
</dd> </dd>
</div> </div>
<div class="rm-storage-sub"> <div class="rm-storage-sub">
<dt>回收站</dt><dd :title="`${usage.trashSize} 字节`"> <dt>{{ translate('common.trash_ba35dc') }}</dt><dd :title="translate('settings.bytes_8180a4', { p0: usage.trashSize })">
{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small> {{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small>
</dd> </dd>
</div> </div>
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ApiToken } from '@@/apis/tokens/type' import type { ApiToken } from '@@/apis/tokens/type'
import type { FormInstance, FormRules } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { createTokenApi, deleteTokenApi, getTokensApi } from '@@/apis/tokens' import { createTokenApi, deleteTokenApi, getTokensApi } from '@@/apis/tokens'
import { locale, t as translate } from '@@/i18n/runtime'
import { copyText } from '@@/utils/clipboard' import { copyText } from '@@/utils/clipboard'
import { confirmAction } from '@@/utils/confirm' import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
@@ -16,15 +18,17 @@ const error = ref('')
const loading = ref(false) const loading = ref(false)
const form = ref<FormInstance>() const form = ref<FormInstance>()
const fields = reactive({ name: '', scope: 'read' as ApiToken['scope'] }) const fields = reactive({ name: '', scope: 'read' as ApiToken['scope'] })
const rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: true, message: '请输入令牌名称', trigger: 'blur' }] } const rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: true, get message() {
return translate('settings.enter_a_token_name_3d7107')
}, trigger: 'blur' }] }
const user = useUserStore() const user = useUserStore()
const busy = ref(false) const busy = ref(false)
// 刚创建的令牌明文,只在本次对话框中显示 // 刚创建的令牌明文,只在本次对话框中显示
const created = ref<string | null>(null) const created = ref<string | null>(null)
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) const fmt = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'short' }))
function errorText(err: unknown) { function errorText(err: unknown) {
return err instanceof ApiError ? err.message : '网络错误,请检查连接' return err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064')
} }
async function load() { async function load() {
@@ -33,7 +37,7 @@ async function load() {
try { try {
tokens.value = (await getTokensApi()).data.tokens tokens.value = (await getTokensApi()).data.tokens
} catch (cause) { } catch (cause) {
if (!isCancel(cause)) error.value = `读取 API 令牌失败:${errorText(cause)}` if (!isCancel(cause)) error.value = translate('settings.could_not_load_api_tokens_98ef9a', { p0: errorText(cause) })
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -52,26 +56,26 @@ async function create() {
fields.name = ''; form.value!.clearValidate() fields.name = ''; form.value!.clearValidate()
} catch (err) { } catch (err) {
if (isCancel(err)) return if (isCancel(err)) return
toast(`创建失败:${errorText(err)}`) toast(translate('settings.could_not_create_token_c6c409', { p0: errorText(err) }))
} finally { } finally {
busy.value = false busy.value = false
} }
} }
async function copy() { async function copy() {
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制') toast((await copyText(created.value!)) ? translate('settings.token_copied_6cca3e') : translate('settings.copy_failed_select_and_copy_cbc861'))
} }
async function revoke(t: ApiToken) { async function revoke(t: ApiToken) {
if (busy.value) return if (busy.value) return
busy.value = true busy.value = true
try { try {
if (!await confirmAction(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return if (!await confirmAction(translate('settings.revoke_scripts_using_this_token_f32cc1', { p0: t.name }))) return
await deleteTokenApi(t.id) await deleteTokenApi(t.id)
tokens.value = tokens.value!.filter(x => x.id !== t.id) tokens.value = tokens.value!.filter(x => x.id !== t.id)
} catch (err) { } catch (err) {
if (isCancel(err)) return if (isCancel(err)) return
toast(`吊销失败:${errorText(err)}`) toast(translate('settings.could_not_revoke_token_8976da', { p0: errorText(err) }))
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -83,57 +87,57 @@ onMounted(load)
<template> <template>
<div> <div>
<el-form ref="form" class="rm-token-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="create"> <el-form ref="form" class="rm-token-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="create">
<el-form-item label="名称" prop="name"> <el-form-item :label="translate('common.name_d44e9b')" prop="name">
<el-input v-model="fields.name" autocomplete="off" placeholder="如:备份脚本" maxlength="100" /> <el-input v-model="fields.name" autocomplete="off" :placeholder="translate('settings.e_g_backup_script_da8956')" maxlength="100" />
</el-form-item> </el-form-item>
<el-form-item label="权限"> <el-form-item :label="translate('settings.permissions_978cbc')">
<el-select v-model="fields.scope" aria-label="权限"> <el-select v-model="fields.scope" :aria-label="translate('settings.permissions_978cbc')">
<el-option value="read" label="只读" /><el-option value="write" label="读写" /> <el-option value="read" :label="translate('settings.read_only_3b5ec3')" /><el-option value="write" :label="translate('settings.read_and_write_d5c332')" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy"> <el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
创建 {{ translate('settings.create_cde2cd') }}
</el-button> </el-button>
</el-form> </el-form>
<div v-if="created" class="rm-token-created"> <div v-if="created" class="rm-token-created">
<p>令牌只显示这一次,请立即保存:</p> <p>{{ translate('settings.this_token_is_shown_only_88a1ba') }}</p>
<div class="rm-token-value"> <div class="rm-token-value">
<code>{{ created }}</code> <code>{{ created }}</code>
<el-button class="rm-btn" @click="copy"> <el-button class="rm-btn" @click="copy">
复制 {{ translate('common.copy_63d90d') }}
</el-button> </el-button>
</div> </div>
</div> </div>
<p class="rm-share-note"> <p class="rm-share-note">
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见 {{ translate('settings.send_the_request_header_02d3d2') }} <code>{{ translate('settings.authorization_bearer_token_3abd9b') }}</code>{{ translate('settings.read_only_tokens_can_only_38418a') }}
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。 <a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
</p> </p>
<p v-if="error" class="rm-auth-error" role="alert"> <p v-if="error" class="rm-auth-error" role="alert">
{{ error }} <el-button text :disabled="loading" @click="load"> {{ error }} <el-button text :disabled="loading" @click="load">
重试 {{ translate('common.retry_b8784c') }}
</el-button> </el-button>
</p> </p>
<p v-if="loading" role="status"> <p v-if="loading" role="status">
读取中… {{ translate('settings.loading_346ff6') }}
</p> </p>
<el-table v-else-if="tokens" :data="tokens" row-key="id" empty-text="还没有 API 令牌"> <el-table v-else-if="tokens" :data="tokens" row-key="id" :empty-text="translate('settings.no_api_tokens_yet_6eae29')">
<!-- @vue-generic {ApiToken} --> <!-- @vue-generic {ApiToken} -->
<el-table-column label="名称" min-width="140"> <el-table-column :label="translate('common.name_d44e9b')" min-width="140">
<template #default="{ row: token }"> <template #default="{ row: token }">
<strong>{{ token.name }}</strong><small class="rm-token-time">{{ fmt.format(token.createdAt) }} 创建 · {{ token.lastUsedAt ? `${fmt.format(token.lastUsedAt)} 最近使用` : '从未使用' }}</small> <strong>{{ token.name }}</strong><small class="rm-token-time">{{ fmt.format(token.createdAt) }} {{ translate('settings.created_3567a7') }} {{ token.lastUsedAt ? translate('settings.last_used_b78fad', { p0: fmt.format(token.lastUsedAt) }) : translate('settings.never_used_458529') }}</small>
</template> </template>
</el-table-column> </el-table-column>
<!-- @vue-generic {ApiToken} --> <!-- @vue-generic {ApiToken} -->
<el-table-column label="权限" width="70"> <el-table-column :label="translate('settings.permissions_978cbc')" width="70">
<template #default="{ row: token }"> <template #default="{ row: token }">
{{ token.scope === 'write' ? '读写' : '只读' }} {{ token.scope === 'write' ? translate('settings.read_and_write_d5c332') : translate('settings.read_only_3b5ec3') }}
</template> </template>
</el-table-column> </el-table-column>
<!-- @vue-generic {ApiToken} --> <!-- @vue-generic {ApiToken} -->
<el-table-column label="操作" width="70"> <el-table-column :label="translate('settings.actions_ed31fb')" width="70">
<template #default="{ row: token }"> <template #default="{ row: token }">
<el-button text type="danger" :disabled="busy" @click="revoke(token)"> <el-button text type="danger" :disabled="busy" @click="revoke(token)">
吊销 {{ translate('settings.revoke_d8c68b') }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TrashItem } from '@@/apis/trash/type' import type { TrashItem } from '@@/apis/trash/type'
import { clearTrashApi, getTrashApi, purgeTrashApi } from '@@/apis/trash'
import { clearTrashApi, getTrashApi, purgeTrashApi } from '@@/apis/trash'
import Icon from '@@/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm' import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { isImage } from '@@/utils/paths' import { isImage } from '@@/utils/paths'
@@ -35,7 +37,7 @@ async function load() {
items.value = (await getTrashApi()).data.items items.value = (await getTrashApi()).data.items
error.value = '' error.value = ''
} catch (cause) { } catch (cause) {
error.value = `读取回收站失败:${String(cause)}` error.value = translate('settings.could_not_load_trash_0d559f', { p0: String(cause) })
} }
} }
@@ -43,7 +45,7 @@ async function retention(value: string) {
const days = value.trim() ? Number(value) : NaN const days = value.trim() ? Number(value) : NaN
if (!Number.isSafeInteger(days) || days < 0) { if (!Number.isSafeInteger(days) || days < 0) {
retentionInput.value = String(settings.trashRetentionDays) retentionInput.value = String(settings.trashRetentionDays)
toast('请输入非负整数天数,0 表示永不自动删除') toast(translate('settings.enter_a_non_negative_number_aff4a0'))
return return
} }
if (days === settings.trashRetentionDays) return if (days === settings.trashRetentionDays) return
@@ -52,10 +54,10 @@ async function retention(value: string) {
await updateSettings({ trashRetentionDays: days }) await updateSettings({ trashRetentionDays: days })
} catch (cause) { } catch (cause) {
if (isCancel(cause)) return if (isCancel(cause)) return
toast(`保存自动删除期限失败:${String(cause)}`) toast(translate('settings.could_not_save_trash_retention_db584b', { p0: String(cause) }))
if (!(cause instanceof SettingsAppearanceError)) { if (!(cause instanceof SettingsAppearanceError)) {
await loadSettings().catch((cause) => { await loadSettings().catch((cause) => {
if (!isCancel(cause)) toast(`重新读取设置失败:${String(cause)}`) if (!isCancel(cause)) toast(translate('settings.could_not_reload_settings_744b6a', { p0: String(cause) }))
}) })
} }
} finally { } finally {
@@ -80,7 +82,7 @@ async function purge(item?: TrashItem) {
failures.value = [] failures.value = []
let attempted = false let attempted = false
try { try {
if (!await confirmAction(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return if (!await confirmAction(item ? translate('settings.permanently_delete_this_cannot_be_8a3d3f', { p0: item.path }) : translate('settings.empty_all_trash_this_cannot_cf818b'))) return
user.assertSession(version) user.assertSession(version)
attempted = true attempted = true
if (item) { if (item) {
@@ -88,34 +90,36 @@ async function purge(item?: TrashItem) {
} else { } else {
const result = (await clearTrashApi()).data const result = (await clearTrashApi()).data
failures.value = result.failed failures.value = result.failed
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`) toast(result.failed.length
? translate('settings.permanently_deleted_with_failures', { count: result.deleted.length, failed: result.failed.length })
: translate('settings.permanently_deleted_items_4712cc', { count: result.deleted.length }))
} }
} catch (cause) { } catch (cause) {
if (isCancel(cause)) return if (isCancel(cause)) return
toast(`删除失败:${String(cause)}`) toast(translate('common.could_not_delete_9619d7', { p0: String(cause) }))
} finally { } finally {
if (attempted && version === user.getSessionVersion()) await load() if (attempted && version === user.getSessionVersion()) await load()
busy.value = false busy.value = false
} }
} }
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) const fmt = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'short' }))
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div> <div>
<SettingRow id="trash-retention" control="rm-trash-retention"> <SettingRow id="trash-retention" control="rm-trash-retention">
<el-input id="rm-trash-retention" v-model="retentionInput" type="number" min="0" step="1" :disabled="saving" aria-label="自动删除天数" @change="retention" /><span>天</span> <el-input id="rm-trash-retention" v-model="retentionInput" type="number" min="0" step="1" :disabled="saving" :aria-label="translate('settings.trash_retention_days_f4ee2a')" @change="retention" /><span>{{ translate('settings.days_49da61') }}</span>
</SettingRow> </SettingRow>
<SettingRow id="trash-clear"> <SettingRow id="trash-clear">
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()"> <el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">
清空全部 {{ translate('settings.empty_trash_1b5909') }}
</el-button> </el-button>
</SettingRow> </SettingRow>
<p v-if="error" role="alert" class="rm-auth-error"> <p v-if="error" role="alert" class="rm-auth-error">
{{ error }} <el-button class="rm-btn" :disabled="busy" @click="load"> {{ error }} <el-button class="rm-btn" :disabled="busy" @click="load">
重试 {{ translate('common.retry_b8784c') }}
</el-button> </el-button>
</p> </p>
<ul v-if="failures.length" role="alert"> <ul v-if="failures.length" role="alert">
@@ -124,14 +128,14 @@ onMounted(load)
</li> </li>
</ul> </ul>
<p v-if="items && !items.length" class="rm-tree-empty"> <p v-if="items && !items.length" class="rm-tree-empty">
回收站是空的 {{ translate('settings.trash_is_empty_9c6705') }}
</p> </p>
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status"> <p v-else-if="items === null && !error" class="rm-tree-empty" role="status">
读取中… {{ translate('settings.loading_346ff6') }}
</p> </p>
<el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" empty-text="回收站是空的"> <el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" :empty-text="translate('settings.trash_is_empty_9c6705')">
<!-- @vue-generic {TrashItem} --> <!-- @vue-generic {TrashItem} -->
<el-table-column label="项目" min-width="170"> <el-table-column :label="translate('settings.item_79f326')" min-width="170">
<template #default="{ row: item }"> <template #default="{ row: item }">
<div class="rm-trash-name"> <div class="rm-trash-name">
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" /><span>{{ item.path.replace(/\.md$/, '') }}</span> <Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" /><span>{{ item.path.replace(/\.md$/, '') }}</span>
@@ -139,12 +143,12 @@ onMounted(load)
</template> </template>
</el-table-column> </el-table-column>
<!-- @vue-generic {TrashItem} --> <!-- @vue-generic {TrashItem} -->
<el-table-column label="操作" width="160"> <el-table-column :label="translate('settings.actions_ed31fb')" width="160">
<template #default="{ row: item }"> <template #default="{ row: item }">
<el-button text :disabled="busy" @click="doRestore(item)"> <el-button text :disabled="busy" @click="doRestore(item)">
恢复 {{ translate('common.restore_e0534b') }}
</el-button><el-button text type="danger" :disabled="busy" @click="purge(item)"> </el-button><el-button text type="danger" :disabled="busy" @click="purge(item)">
彻底删除 {{ translate('settings.delete_permanently_7aa6d0') }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -1,4 +1,5 @@
import type { Settings } from '@/pinia/stores/settings' import type { Settings } from '@/pinia/stores/settings'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings' import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
@@ -11,12 +12,12 @@ export function useSettingsChange() {
return true return true
} catch (cause) { } catch (cause) {
if (isCancel(cause)) return false if (isCancel(cause)) return false
toast(cause instanceof Error ? cause.message : '保存设置失败') toast(cause instanceof Error ? cause.message : translate('settings.could_not_save_settings_17b49d'))
if (cause instanceof SettingsAppearanceError) return true if (cause instanceof SettingsAppearanceError) return true
try { try {
await store.loadSettings() await store.loadSettings()
} catch (reload) { } catch (reload) {
if (!isCancel(reload)) toast(`重新读取设置失败:${String(reload)}`) if (!isCancel(reload)) toast(translate('settings.could_not_reload_settings_744b6a', { p0: String(reload) }))
} }
return false return false
} }
@@ -48,7 +48,7 @@ it('手动插件刷新只请求一次,失败在当前面板保留原因', asyn
wrapper = mount(PluginsPanel, { global }) wrapper = mount(PluginsPanel, { global })
await flushPromises() await flushPromises()
vi.mocked(plugins.refreshPlugins).mockClear().mockRejectedValueOnce(new Error('插件文件读取失败')) vi.mocked(plugins.refreshPlugins).mockClear().mockRejectedValueOnce(new Error('插件文件读取失败'))
await wrapper.findAll('button').find(item => item.text() === '刷新列表')!.trigger('click') await wrapper.findAll('button').find(item => item.text() === 'Refresh list')!.trigger('click')
await flushPromises() await flushPromises()
expect(plugins.refreshPlugins).toHaveBeenCalledOnce() expect(plugins.refreshPlugins).toHaveBeenCalledOnce()
expect(wrapper.findAll('.rm-auth-error')).toHaveLength(1) expect(wrapper.findAll('.rm-auth-error')).toHaveLength(1)
@@ -64,7 +64,7 @@ it('重置已成功但插件同步失败时,说明已完成的操作,不显
await wrapper.get('#rm-setting-settings-reset button').trigger('click') await wrapper.get('#rm-setting-settings-reset button').trigger('click')
await flushPromises() await flushPromises()
expect(reset).toHaveBeenCalledOnce() expect(reset).toHaveBeenCalledOnce()
expect(wrapper.get('[role="alert"]').text()).toContain('设置已重置,原设置已备份,但同步失败:插件服务暂时不可用') expect(wrapper.get('[role="alert"]').text()).toContain('Settings reset. Previous settings backed up, but synchronization failed: 插件服务暂时不可用')
expect(wrapper.find('[role="status"]').exists()).toBe(false) expect(wrapper.find('[role="status"]').exists()).toBe(false)
}) })
@@ -72,7 +72,7 @@ it('设置重置后的同步被会话切换取消时不提示失败', async () =
vi.spyOn(maintenanceApi, 'resetSettingsApi').mockResolvedValue(apiSuccess({ ok: true })) vi.spyOn(maintenanceApi, 'resetSettingsApi').mockResolvedValue(apiSuccess({ ok: true }))
wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global }) wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global })
await flushPromises() await flushPromises()
vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new CanceledError('会话已变化')) vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new CanceledError('Session changed'))
await wrapper.get('#rm-setting-settings-reset button').trigger('click') await wrapper.get('#rm-setting-settings-reset button').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false) expect(wrapper.find('[role="alert"]').exists()).toBe(false)
@@ -108,7 +108,7 @@ it('设置导入已提交但同步失败时保留完成信息,不取消已提
await input.trigger('change') await input.trigger('change')
await flushPromises() await flushPromises()
expect(imported).toHaveBeenCalledExactlyOnceWith('settings-upload') expect(imported).toHaveBeenCalledExactlyOnceWith('settings-upload')
expect(wrapper.get('[role="alert"]').text()).toContain('设置与资源已导入,原设置已备份,但同步失败:插件列表读取失败') expect(wrapper.get('[role="alert"]').text()).toContain('Settings and resources imported. Previous settings backed up, but synchronization failed: 插件列表读取失败')
expect(wrapper.find('[role="status"]').exists()).toBe(false) expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(cancel).not.toHaveBeenCalled() expect(cancel).not.toHaveBeenCalled()
}) })
+19 -17
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SettingSearchResult } from './settings-catalog' import type { SettingSearchResult } from './settings-catalog'
import type { SettingsSection } from '@/pinia/stores/settings' import type { SettingsSection } from '@/pinia/stores/settings'
import Icon from '@@/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { runCommand } from '@@/utils/commands' import { runCommand } from '@@/utils/commands'
import { installApp, installPrompt } from '@@/utils/pwa' import { installApp, installPrompt } from '@@/utils/pwa'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
@@ -19,8 +21,8 @@ import { searchSettings, SETTINGS_SECTIONS } from './settings-catalog'
const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>() const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>()
const emit = defineEmits<{ importNotes: [] }>() const emit = defineEmits<{ importNotes: [] }>()
const { settingsSave } = useSettingsStore() const { settingsSave } = useSettingsStore()
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content const buildVersion = import.meta.env.DEV ? translate('settings.development_0d1c11') : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
if (!buildVersion) throw new Error('缺少应用构建版本信息') if (!buildVersion) throw new Error(translate('settings.application_build_information_is_missing_6b04f9'))
const model = defineModel<SettingsSection | 'index' | null>({ required: true }) const model = defineModel<SettingsSection | 'index' | null>({ required: true })
const section = computed(() => model.value === 'index' || model.value === null ? 'appearance' : model.value) const section = computed(() => model.value === 'index' || model.value === null ? 'appearance' : model.value)
const showList = ref(model.value === 'index') const showList = ref(model.value === 'index')
@@ -31,7 +33,7 @@ const results = computed(() => searchSettings(query.value))
const content = ref<HTMLElement>() const content = ref<HTMLElement>()
const heading = ref<HTMLElement>() const heading = ref<HTMLElement>()
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value)) const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存') const saveLabel = computed(() => settingsSave.pending ? translate('settings.saving_ff509c') : settingsSave.result === 'error' ? translate('settings.save_failed_6309a3') : settingsSave.result === 'saved' ? translate('settings.saved_1bd91a') : translate('settings.changes_save_automatically_b88990'))
async function open(id: SettingsSection) { async function open(id: SettingsSection) {
query.value = ''; model.value = id; showList.value = false query.value = ''; model.value = id; showList.value = false
await nextTick() await nextTick()
@@ -58,16 +60,16 @@ function close() {
</script> </script>
<template> <template>
<el-dialog :model-value="model !== null" class="rm-settings-modal" title="设置" :show-close="false" :close-on-click-modal="true" align-center destroy-on-close @close="close"> <el-dialog :model-value="model !== null" class="rm-settings-modal" :title="translate('common.settings_df3d58')" :show-close="false" :close-on-click-modal="true" align-center destroy-on-close @close="close">
<div class="rm-settings" :class="{ 'show-list': showList, searching }"> <div class="rm-settings" :class="{ 'show-list': showList, searching }">
<nav class="rm-settings-nav" aria-label="设置分类"> <nav class="rm-settings-nav" :aria-label="translate('settings.settings_categories_dfb699')">
<div class="rm-dialog-head"> <div class="rm-dialog-head">
<h2>设置</h2> <h2>{{ translate('common.settings_df3d58') }}</h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭设置" @click="close"> <button class="rm-icon-btn rm-settings-close-list" :title="translate('settings.close_settings_77b17f')" @click="close">
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<el-input v-model="query" class="rm-settings-search" type="search" clearable aria-label="搜索设置" placeholder="搜索设置"> <el-input v-model="query" class="rm-settings-search" type="search" clearable :aria-label="translate('settings.search_settings_287415')" :placeholder="translate('settings.search_settings_287415')">
<template #prefix> <template #prefix>
<Icon name="search" /> <Icon name="search" />
</template> </template>
@@ -83,21 +85,21 @@ function close() {
</nav> </nav>
<section class="rm-settings-body"> <section class="rm-settings-body">
<div class="rm-dialog-head"> <div class="rm-dialog-head">
<button class="rm-icon-btn rm-settings-back" title="返回设置分类" @click="back"> <button class="rm-icon-btn rm-settings-back" :title="translate('settings.back_to_settings_categories_2b3f9b')" @click="back">
<Icon name="chevron" class="flip" /> <Icon name="chevron" class="flip" />
</button> </button>
<h2 ref="heading" tabindex="-1"> <h2 ref="heading" tabindex="-1">
{{ searching ? '搜索结果' : current.title }} {{ searching ? translate('common.search_results_88d72e') : current.title }}
</h2> </h2>
<span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || '偏好设置保存在服务器上,所有设备共用'" role="status" aria-live="polite">{{ saveLabel }}</span> <span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || translate('settings.preferences_are_saved_on_the_adf9bd')" role="status" aria-live="polite">{{ saveLabel }}</span>
<button class="rm-icon-btn" title="关闭设置" @click="close"> <button class="rm-icon-btn" :title="translate('settings.close_settings_77b17f')" @click="close">
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<div ref="content" class="rm-settings-content"> <div ref="content" class="rm-settings-content">
<div v-if="searching" class="rm-settings-results"> <div v-if="searching" class="rm-settings-results">
<p class="rm-settings-result-count" role="status"> <p class="rm-settings-result-count" role="status">
{{ results.length ? `找到 ${results.length} 项设置` : '没有匹配的设置,试试其他关键词。' }} {{ results.length ? translate('settings.found_settings_24ca43', { count: results.length }) : translate('settings.no_matching_settings_try_different_3747bc') }}
</p> </p>
<button v-for="result in results" :key="result.id || result.section" class="rm-settings-result" @click="locate(result)"> <button v-for="result in results" :key="result.id || result.section" class="rm-settings-result" @click="locate(result)">
<span><strong>{{ result.title }}</strong><small>{{ result.description }}</small></span><small>{{ SETTINGS_SECTIONS.find(s => s.id === result.section)!.title }}</small><Icon name="chevron" /> <span><strong>{{ result.title }}</strong><small>{{ result.description }}</small></span><small>{{ SETTINGS_SECTIONS.find(s => s.id === result.section)!.title }}</small><Icon name="chevron" />
@@ -115,18 +117,18 @@ function close() {
<StoragePanel v-else-if="section === 'storage'" :prepare-maintenance="props.prepareMaintenance" @import-notes="emit('importNotes')" /> <StoragePanel v-else-if="section === 'storage'" :prepare-maintenance="props.prepareMaintenance" @import-notes="emit('importNotes')" />
<template v-else-if="section === 'about'"> <template v-else-if="section === 'about'">
<p class="rm-share-note"> <p class="rm-share-note">
RawMark · 纯 Markdown 笔记编辑器,文件即真源。 {{ translate('settings.rawmark_a_markdown_note_editor_66b52a') }}
</p> </p>
<SettingRow id="version"> <SettingRow id="version">
<code>{{ buildVersion }}</code> <code>{{ buildVersion }}</code>
</SettingRow> </SettingRow>
<SettingRow id="install"> <SettingRow id="install">
<el-button v-if="installPrompt" class="rm-btn" @click="installApp"> <el-button v-if="installPrompt" class="rm-btn" @click="installApp">
安装 RawMark {{ translate('settings.install_rawmark_8ccabe') }}
</el-button><span v-else class="rm-settings-unavailable">当前浏览器暂未提供安装入口</span> </el-button><span v-else class="rm-settings-unavailable">{{ translate('settings.this_browser_does_not_currently_d6d77a') }}</span>
</SettingRow> </SettingRow>
<SettingRow id="openapi"> <SettingRow id="openapi">
<a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a> <a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">{{ translate('settings.view_openapi_documentation_4f11fd') }}</a>
</SettingRow> </SettingRow>
</template> </template>
</div> </div>
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more