diff --git a/AGENTS.md b/AGENTS.md index 30fc630..49891c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,12 @@ RawMark 是单用户 Markdown 网页笔记编辑器。文件是真源;Go 标 - 模块内部优先相对导入,跨职责使用 `@`(`web/src`)和 `@@`(`web/src/common`)。Vue 组件使用 PascalCase,其他文件使用 kebab-case;SFC 使用 Composition API 与 TypeScript。既有编辑器主题、全局 CSS 和专用 DOM 控件按业务保留,普通表单优先 Element Plus。 - 外部未知数据使用 unknown,在实际边界验证;不以 any、类型忽略或假数据掩盖契约。沿用 ESLint/Antfu,不增加另一套格式化工具。 +## 多语言 + +- 使用 Vue I18n Composition API,默认英文 `en`、可选简体中文 `zh-CN`;语言存于现有账户设置,登录页选择仅作本机临时显示,登录后以服务端设置覆盖。 +- 界面文案使用稳定消息键;公共字典放 `common/i18n/`,页面私有字典随模块,由应用入口装配,公共代码不得反向导入页面。命令、设置搜索和编辑器 DOM 控件需支持即时切换,不重建正文或丢失编辑历史。用户内容、文件名及插件自带文字不翻译。 +- Axios 发送 `Accept-Language`,API 返回 `Content-Language`;默认英文,状态码、业务码和响应形状不随语言改变。公开错误保留消息 ID、参数和原始错误链,在 HTTP 边界生成译文,不匹配已翻译文字判断错误。 + ## 认证与接口契约 - 登录是密码加后端六位图片验证码,首次初始化设置密码;无用户名、注册或角色系统。验证码有效五分钟,绑定挑战及来源,每次登录提交一次性消费,失败必须刷新;保留签发和登录限流。 diff --git a/README.md b/README.md index 567be84..31d8e66 100644 --- a/README.md +++ b/README.md @@ -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. - **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. -- **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. RawMark is built for **single-user, self-hosted use**. It currently does not offer collaboration or public sharing. diff --git a/README.zh-CN.md b/README.zh-CN.md index 04c649b..8222c5d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,7 +29,7 @@ RawMark 适合想把笔记保存在自己服务器上的个人用户:整理知 - **有条理地整理**:文件夹、标签、全文搜索与文章大纲;拖动移动、批量操作、自定义文件图标,让笔记各归其位。 - **延续工作状态**:多标签页与固定标签,刷新或重启后恢复;自动保存,外部文件修改同步到页面。 - **让图片跟着笔记走**:粘贴、上传与批量导入图片;图片管理支持引用查看、重命名、移动、替换及孤立图片清理。 -- **按自己的习惯调整**:浅色与深色模式,独立的界面、正文及代码字体,可导入字体、自定义快捷键、主题和 CSS 片段。 +- **按自己的习惯调整**:英文与简体中文、浅色与深色模式,独立的界面、正文及代码字体,可导入字体、自定义快捷键、主题和 CSS 片段。 - **数据可带走**:导入 Markdown 或 ZIP,导出笔记与附件;回收站、完整实例备份与设置迁移,支持 API 自动化及前端插件扩展。 RawMark 面向**单用户、自托管**场景。目前不提供多人协作或公开分享。 diff --git a/docs/api.md b/docs/api.md index 837f229..0d46f21 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,7 +27,7 @@ curl --fail-with-body \ { "code": 0, "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: *` 要求目标不存在;都不带会直接覆盖,不适合需要冲突保护的写入。 +## 响应语言 + +发送 `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 }` 设置密码并返回会话;已经初始化的实例不能再次初始化。 diff --git a/docs/development.md b/docs/development.md index 4e45b80..2aec60f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 的编辑器、主题和专用交互按业务保留。 提交前运行适用检查,说明实际验证与未验证范围。Commit 使用清晰英文,按功能拆分;不要提交真实笔记、账户数据、密钥或本地交接文件。 + +## 多语言 + +使用 Vue I18n Composition API,支持 `en` 与 `zh-CN`,默认英文。公共字典位于 `web/src/common/i18n/`,模块私有字典位于 `pages//locales/`,应用 `src/i18n.ts` 装配字典;不要用中文原文作为运行时查找键,也不要从公共模块导入页面字典。模板及命令、设置搜索等长期对象在使用时翻译;日期、数字和排序读取当前语言。 + +账户语言沿用 settings store 的串行写入、服务器设置与事件同步;登录页仅更新本机启动缓存,认证后读取服务器值。CodeMirror 使用 locale Compartment 更新控件,不替换文档、选区或撤销历史。用户正文、现有路径和插件提供的文字保持原样。 + +后端 `internal/i18n/` 保存消息 ID 与参数,错误在 HTTP 边界根据 `Accept-Language` 渲染,并保留 `errors.Is/As` 及原始诊断;日志遵循原有语言。既有索引警告只在需要时重新分析源笔记生成译文,同时更新该条目的完整元数据和版本,不改索引格式;已删除或移动的过期条目跳过,其他读取错误明确返回。新增文案同时补充中英字典与参数,运行字典完整性、设置同步、编辑器和 API 的相应测试。 diff --git a/docs/guide.md b/docs/guide.md index 5d934f8..edbcd63 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -65,3 +65,7 @@ ZIP 会导入到同名新文件夹,保留笔记层级。本地图片归入各 插件由实例管理者安装,属于有完整前端权限的扩展,请只安装可信来源;不提供沙箱隔离保证。开发入口见[开发指南](development.md#主题字体与插件)。 插件导致界面异常时,可在地址后加 `?disable-plugins` 暂停插件并进入管理。主题或 CSS 片段导致界面不可用时,可加 `?reset-appearance` 临时停用自定义外观,再进入设置恢复默认。这些入口不自动删除笔记或丢弃编辑内容。 + +## 界面语言 + +默认使用英文。在 **Settings → Appearance → Language** 选择 **简体中文**;中文界面对应「设置 → 外观 → 语言」。更改立即生效并保存在账户设置中,所有设备共用,不改写笔记内容或已有文件名。登录页也可以临时切换语言,登录后以账户偏好为准。 diff --git a/internal/api/api.go b/internal/api/api.go index 5481b57..a999040 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -12,6 +12,7 @@ import ( "net/http" "path" "rawmark/internal/backup" + "rawmark/internal/i18n" "strings" "sync" "sync/atomic" @@ -162,9 +163,11 @@ func (s *Server) Handler(static fs.FS) http.Handler { } 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)) - 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 origin := r.Header.Get("Origin"); origin != "" { 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 } } @@ -216,9 +219,9 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) { } switch err := s.auth.Setup(body.Password); { 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): - writeError(w, 409, err.Error()) + writeError(w, 409, i18n.RenderError(err, responseLocale(w))) case err != nil: internalError(w, r, err) 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) switch { case errors.Is(err, auth.ErrRateLimited): - writeError(w, 429, err.Error()) + writeError(w, 429, i18n.RenderError(err, responseLocale(w))) 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): - writeFailure(w, 400, CodeCaptchaExpired, err.Error(), nil) + writeFailure(w, 400, CodeCaptchaExpired, i18n.RenderError(err, responseLocale(w)), nil) 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: internalError(w, r, err) default: @@ -294,7 +297,7 @@ func (s *Server) setTreeIcon(w http.ResponseWriter, r *http.Request) { } var icon *vault.TreeIcon 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 } 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) { data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes)) if err != nil { - writeError(w, 413, "笔记过大") + writeError(w, 413, message(w, "note_is_too_large_86d890")) return } 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) file, header, err := r.FormFile("file") if err != nil { - writeError(w, 400, "缺少上传文件") + writeError(w, 400, message(w, "upload_file_is_missing_a2be00")) return } defer file.Close() var link string if r.URL.Query().Has("folder") { if r.URL.Query().Has("note") { - writeError(w, 400, "不能同时指定笔记和文件夹") + writeError(w, 400, message(w, "a_note_and_a_folder_8b62b5")) return } 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 { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { - writeError(w, 413, "图片不能超过 50 MB") + writeError(w, 413, message(w, "images_must_not_exceed_mb_61d721")) } else { - writeError(w, 400, "无法读取上传文件") + writeError(w, 400, message(w, "could_not_read_uploaded_file_4f04cc")) } return } defer r.MultipartForm.RemoveAll() file, header, err := r.FormFile("file") if err != nil { - writeError(w, 400, "缺少上传文件") + writeError(w, 400, message(w, "upload_file_is_missing_a2be00")) return } defer file.Close() @@ -504,7 +507,7 @@ func clientIP(r *http.Request) string { 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 { - writeError(w, 400, "请求格式错误") + writeError(w, 400, message(w, "invalid_request_format_2529b1")) return false } 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) { 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) { var conflict *vault.ConflictError switch { 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): - writeError(w, 400, err.Error()) + writeError(w, 400, i18n.RenderError(err, responseLocale(w))) 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): - writeError(w, 409, err.Error()) + writeError(w, 409, i18n.RenderError(err, responseLocale(w))) case errors.Is(err, vault.ErrTooLarge): - writeError(w, 413, err.Error()) + writeError(w, 413, i18n.RenderError(err, responseLocale(w))) default: internalError(w, r, err) } @@ -539,5 +542,27 @@ func (s *Server) metadata(w http.ResponseWriter, r *http.Request) { internalError(w, r, err) 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}) } diff --git a/internal/api/assets.go b/internal/api/assets.go index da4f66a..66e4b09 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -1,6 +1,7 @@ package api import "net/http" +import "rawmark/internal/i18n" func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { var body struct { @@ -10,7 +11,7 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { return } 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 } 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) return } + for i := range result.Skipped { + result.Skipped[i].Reason = i18n.RenderError(result.Skipped[i].Cause, responseLocale(w)) + } writeJSON(w, 200, result) } diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index d17ca2b..bb3ec24 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -34,7 +34,7 @@ func TestImageCleanupRejectsDirectories(t *testing.T) { t.Fatalf("清理应逐项汇报:%d", res.StatusCode) } 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) } kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil) diff --git a/internal/api/authentication.go b/internal/api/authentication.go index dc064c3..eb3e330 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -33,7 +33,7 @@ func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler { header := r.Header.Get("Authorization") token, ok := strings.CutPrefix(header, "Bearer ") if !ok || token == "" || strings.ContainsAny(token, " \t\r\n") { - writeError(w, 401, "请先登录") + writeError(w, 401, message(w, "please_sign_in_48144f")) return } p := principal{Token: token} @@ -50,16 +50,16 @@ func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler { return } if !valid { - writeError(w, 401, "登录凭据无效或已过期") + writeError(w, 401, message(w, "credentials_are_invalid_or_expired_e1f63b")) return } if p.SessionHash == "" { if !allowAPI { - writeError(w, 403, "此接口需要登录会话") + writeError(w, 403, message(w, "this_endpoint_requires_a_login_d34b96")) return } 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 } } diff --git a/internal/api/captcha.go b/internal/api/captcha.go index 92c7200..e429f82 100644 --- a/internal/api/captcha.go +++ b/internal/api/captcha.go @@ -5,8 +5,8 @@ import ( "crypto/rand" "crypto/subtle" "encoding/base64" - "errors" "net/http" + "rawmark/internal/i18n" "sync" "time" @@ -15,8 +15,8 @@ import ( ) var ( - errCaptchaInvalid = errors.New("验证码错误或已使用,请刷新后重试") - errCaptchaExpired = errors.New("验证码已过期,请刷新后重试") + errCaptchaInvalid = i18n.New("the_verification_code_is_incorrect_6e789b") + errCaptchaExpired = i18n.New("the_verification_code_has_expired_0b3384") ) 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) if err != nil { - writeError(w, 429, err.Error()) + writeError(w, 429, i18n.RenderError(err, responseLocale(w))) return } var png bytes.Buffer diff --git a/internal/api/fonts.go b/internal/api/fonts.go index 7e1b839..818f53e 100644 --- a/internal/api/fonts.go +++ b/internal/api/fonts.go @@ -5,12 +5,12 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "io" "io/fs" "net/http" "os" "path/filepath" + "rawmark/internal/i18n" "regexp" "strings" @@ -46,7 +46,7 @@ func (s *Server) openFont(id string) (*os.Root, fontInfo, error) { err = json.Unmarshal(data, &info) } 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 { 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) { r.Body = http.MaxBytesReader(w, r.Body, maxFontBytes+(1<<20)) 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 } defer r.MultipartForm.RemoveAll() f, header, err := r.FormFile("file") if err != nil { - writeError(w, 400, "请选择字体文件") + writeError(w, 400, message(w, "choose_a_font_file_4c95b9")) return } defer f.Close() @@ -105,23 +105,23 @@ func (s *Server) importFont(w http.ResponseWriter, r *http.Request) { return } if len(data) > maxFontBytes { - writeError(w, 413, "字体文件超过 30 MB") + writeError(w, 413, message(w, "font_file_exceeds_mb_cc001c")) return } 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] { - writeError(w, 400, "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件") + writeError(w, 400, message(w, "import_a_valid_ttf_otf_b67fd3")) return } if s.FontsDir == "" { - writeError(w, 503, "字体目录未配置") + writeError(w, 503, message(w, "font_directory_is_not_configured_73d32a")) return } hash := sha256.Sum256(data) id := "custom-" + hex.EncodeToString(hash[:]) 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") { - writeError(w, 400, "字体文件名无效或过长") + writeError(w, 400, message(w, "font_filename_is_invalid_or_a3fd5e")) return } 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")) if errors.Is(err, fs.ErrNotExist) { s.settingsMu.Unlock() - writeError(w, 404, "字体不存在") + writeError(w, 404, message(w, "font_not_found_4383e9")) return } if err != nil { @@ -186,7 +186,7 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) { stat, err := f.Stat() if err != nil || !stat.Mode().IsRegular() { if err == nil { - err = fmt.Errorf("字体必须为普通文件") + err = i18n.New("a_font_must_be_a_5b8b51") } internalError(w, r, err) 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) { id := r.PathValue("id") if id == "maple" { - writeError(w, 400, "Maple 是默认字体,不可删除") + writeError(w, 400, message(w, "maple_is_the_default_font_a8a571")) return } s.settingsMu.Lock() defer s.settingsMu.Unlock() dir, _, err := s.openFont(id) if errors.Is(err, fs.ErrNotExist) { - writeError(w, 404, "字体不存在") + writeError(w, 404, message(w, "font_not_found_4383e9")) return } if err != nil { @@ -247,7 +247,7 @@ func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) { } s.hub.Broadcast(events.Event{Type: "settings"}) 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 } writeJSON(w, 200, map[string]bool{"ok": true}) diff --git a/internal/api/import.go b/internal/api/import.go index 531037f..8076c2a 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -4,13 +4,13 @@ import ( "bytes" "context" "errors" - "fmt" "io" "log/slog" "net" "net/http" "net/netip" "net/url" + "rawmark/internal/i18n" "syscall" "time" @@ -18,8 +18,8 @@ import ( ) var ( - errImageAddress = errors.New("图片地址必须是公网 HTTP 或 HTTPS 地址") - errImageType = errors.New("仅支持导入 PNG、JPEG、GIF 和 WebP 图片") + errImageAddress = i18n.New("image_url_must_be_a_391763") + errImageType = i18n.New("only_png_jpeg_gif_and_a413cf") ) var blockedImageNetworks = []netip.Prefix{ @@ -98,7 +98,7 @@ var remoteImageClient = &http.Client{ }, CheckRedirect: func(r *http.Request, via []*http.Request) error { if len(via) >= 5 { - return errors.New("图片来源重定向次数过多") + return i18n.New("too_many_redirects_from_the_6738ba") } r.Header.Del("Referer") return checkImageURL(r.URL) @@ -137,12 +137,12 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) { } u, err := url.Parse(body.URL) if err != nil || checkImageURL(u) != nil { - writeError(w, 400, errImageAddress.Error()) + writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) return } note := r.URL.Query().Get("note") if !vault.IsNote(note) { - writeError(w, 400, "所属笔记路径无效") + writeError(w, 400, message(w, "invalid_note_path_be69bd")) return } 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) if err != nil { - writeError(w, 400, errImageAddress.Error()) + writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) return } res, err := remoteImageClient.Do(req) @@ -171,7 +171,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) { continue } if errors.Is(err, errImageAddress) { - writeError(w, 400, errImageAddress.Error()) + writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) return } // url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。 @@ -180,7 +180,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) { err = urlError.Err } slog.Error("下载图片失败", "err", err) - writeError(w, 502, "下载图片失败") + writeError(w, 502, message(w, "could_not_download_image_b4a293")) return } 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) { continue } - writeError(w, 502, fmt.Sprintf("图片来源返回 HTTP %d", res.StatusCode)) + writeError(w, 502, message(w, "image_source_http", res.StatusCode)) return } 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) { vaultError(w, r, err) } else if errors.Is(err, errImageType) { - writeError(w, 422, err.Error()) + writeError(w, 422, i18n.RenderError(err, responseLocale(w))) } else { slog.Error("读取下载图片失败", "err", err) - writeError(w, 502, "读取下载图片失败") + writeError(w, 502, message(w, "could_not_read_downloaded_image_4b7fdf")) } return } @@ -215,7 +215,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) { return } if info.IsDir() { - writeError(w, 400, "所属路径必须是笔记文件") + writeError(w, 400, message(w, "the_owning_path_must_be_867b53")) return } link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes) diff --git a/internal/api/import_notes.go b/internal/api/import_notes.go index 7fca4b7..3c7e23c 100644 --- a/internal/api/import_notes.go +++ b/internal/api/import_notes.go @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" + "rawmark/internal/i18n" "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 { var limit *http.MaxBytesError if errors.As(err, &limit) { - writeError(w, 413, "导入文件总量不能超过 100 MB") + writeError(w, 413, message(w, "total_import_size_must_not_23ac37")) } else { - writeError(w, 400, "无法读取导入表单") + writeError(w, 400, message(w, "could_not_read_import_form_69f1c3")) } return } defer r.MultipartForm.RemoveAll() headers := r.MultipartForm.File["files"] 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 } 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) return } + for i, warning := range result.WarningMessages { + result.Warnings[i] = i18n.RenderError(warning, responseLocale(w)) + } writeJSON(w, http.StatusCreated, result) } diff --git a/internal/api/locale.go b/internal/api/locale.go new file mode 100644 index 0000000..bb9d70c --- /dev/null +++ b/internal/api/locale.go @@ -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 +} diff --git a/internal/api/locale_test.go b/internal/api/locale_test.go new file mode 100644 index 0000000..29f0404 --- /dev/null +++ b/internal/api/locale_test.go @@ -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) + } +} diff --git a/internal/api/maintenance.go b/internal/api/maintenance.go index 9f39a72..01eeed0 100644 --- a/internal/api/maintenance.go +++ b/internal/api/maintenance.go @@ -5,12 +5,12 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "mime" "net/http" "os" "path/filepath" + "rawmark/internal/i18n" "runtime" "strings" "syscall" @@ -26,6 +26,7 @@ type maintenanceStatus struct { Error string `json:"error"` FinishedAt int64 `json:"finishedAt"` Backup string `json:"backup,omitempty"` + cause error } // gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。 @@ -36,7 +37,7 @@ func (s *Server) guardAPI(next http.Handler) http.Handler { return } 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 } resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/") @@ -47,7 +48,7 @@ func (s *Server) guardAPI(next http.Handler) http.Handler { return } if !s.activity.TryRLock() { - writeError(w, 503, "正在维护,请稍后重试") + writeError(w, 503, message(w, "maintenance_is_in_progress_please_64ef9d")) return } defer s.activity.RUnlock() @@ -55,8 +56,8 @@ func (s *Server) guardAPI(next http.Handler) http.Handler { }) } -var errMaintenanceBusy = errors.New("其他维护操作正在执行") -var errMaintenanceInput = errors.New("维护请求无效") +var errMaintenanceBusy = i18n.New("another_maintenance_operation_is_in_da1aa4") +var errMaintenanceInput = i18n.New("invalid_maintenance_request_ef9c16") func (s *Server) maintain(operation string, fn func() error) error { if !s.maintenanceMu.TryLock() { @@ -75,6 +76,7 @@ func (s *Server) maintain(operation string, fn func() error) error { s.maintenanceState.FinishedAt = time.Now().UnixMilli() if err != nil { s.maintenanceState.Error = err.Error() + s.maintenanceState.cause = err } s.maintenanceStatusMu.Unlock() 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) { s.maintenanceStatusMu.Lock() 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) { var pathError *os.PathError switch { case errors.Is(err, errMaintenanceBusy): - writeError(w, 503, err.Error()) + writeError(w, 503, i18n.RenderError(err, responseLocale(w))) 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): internalError(w, r, err) 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: 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) { kind := r.PathValue("kind") if kind != "full" && kind != "settings" { - writeError(w, 400, "备份类型无效") + writeError(w, 400, message(w, "invalid_backup_type_f5ff70")) return } temp, err := s.tempDir() @@ -217,7 +226,7 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) { file.Close() 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 { 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) { - err := s.maintain("导出笔记数据", func() error { + err := s.maintain("export_note_data_45a4e1", func() error { dataRoot, err := filepath.EvalSymlinks(s.DataDir) if err != nil { return err @@ -253,39 +262,42 @@ func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) { func validatePreferences(values map[string]json.RawMessage) error { for key, raw := range values { if len(raw) > maxJSONBytes { - return fmt.Errorf("%w:单项设置超过 1 MiB", errMaintenanceInput) + return i18n.New("w_a_single_setting_exceeds_c7dccb", errMaintenanceInput) } switch { case key == settingsKey: var settings map[string]json.RawMessage 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 { - return fmt.Errorf("%w:%w", errMaintenanceInput, err) + return i18n.New("w_w_f0fd7d", errMaintenanceInput, err) } if value, ok := settings["shortcuts"]; ok { var shortcuts map[string][]string 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: var ids map[string]bool 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 { if !validPluginID(id) { - return fmt.Errorf("%w:插件 ID 无效", errMaintenanceInput) + return i18n.New("w_invalid_plugin_id_7f3512", errMaintenanceInput) } } case strings.HasPrefix(key, "plugin.data."): 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: - return fmt.Errorf("%w:设置包包含不允许的设置项", errMaintenanceInput) + return i18n.New("w_the_settings_package_contains_e70b59", errMaintenanceInput) } } return nil @@ -296,7 +308,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) { return nil, err } 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")) if err != nil { @@ -304,7 +316,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) { } var values map[string]json.RawMessage 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 { return nil, err @@ -322,16 +334,16 @@ func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) { return } if !body.Confirmed { - writeError(w, 400, "请先确认导入设置") + writeError(w, 400, message(w, "confirm_settings_import_first_e89aa3")) return } - err := s.maintain("导入设置", func() error { + err := s.maintain("import_settings_1a80d4", func() error { meta, dir, err := s.readUpload(r, body.ID) if err != nil { return err } 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") values, err := readPreferences(stage) @@ -436,10 +448,10 @@ func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) { return } if !body.Confirmed { - writeError(w, 400, "请先确认重置") + writeError(w, 400, message(w, "confirm_reset_first_22ae3d")) return } - err := s.maintain("重置设置", func() error { + err := s.maintain("reset_settings_9a70ef", func() error { if _, err := s.saveRollback(r.Context(), "settings"); err != nil { 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) { operation := r.PathValue("operation") if operation != "optimize" && operation != "rebuild" { - writeError(w, 400, "索引操作无效") + writeError(w, 400, message(w, "invalid_index_operation_be898b")) return } - err := s.maintain("索引维护", func() error { + err := s.maintain("index_maintenance_676706", func() error { if operation == "rebuild" { 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) { if s.Logs == nil { - writeError(w, 503, "当前服务尚未启用日志文件") + writeError(w, 503, message(w, "log_files_are_not_enabled_dcf4df")) return } - err := s.maintain("导出系统日志", func() error { + err := s.maintain("export_system_logs_d05855", func() error { temp, err := s.tempDir() if err != nil { return err diff --git a/internal/api/maintenance_test.go b/internal/api/maintenance_test.go index 69ff677..2f31857 100644 --- a/internal/api/maintenance_test.go +++ b/internal/api/maintenance_test.go @@ -307,7 +307,7 @@ func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) { if err := json.NewDecoder(res.Body).Decode(&body); err != nil { 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) } }) @@ -348,7 +348,7 @@ func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) { if err := json.NewDecoder(res.Body).Decode(&body); err != nil { 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) } }) diff --git a/internal/api/maintenance_uploads.go b/internal/api/maintenance_uploads.go index 34e184f..902ba00 100644 --- a/internal/api/maintenance_uploads.go +++ b/internal/api/maintenance_uploads.go @@ -5,11 +5,11 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "io" "net/http" "os" "path/filepath" + "rawmark/internal/i18n" "regexp" "strconv" "strings" @@ -39,12 +39,12 @@ func uploadOwner(r *http.Request) string { func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) { var meta maintenanceUpload 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) data, err := os.ReadFile(filepath.Join(dir, "upload.json")) 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 { return meta, "", err @@ -53,7 +53,7 @@ func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, stri return meta, "", err } 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 } @@ -70,7 +70,7 @@ func enoughSpace(dir string, size int64) error { return err } 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 } @@ -83,11 +83,11 @@ func (s *Server) startMaintenanceUpload(w http.ResponseWriter, r *http.Request) return } 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 } var id string - err := s.maintain("准备上传", func() error { + err := s.maintain("prepare_upload_ed0bd9", func() error { root := filepath.Join(s.DataDir, ".maintenance", "uploads") if err := os.MkdirAll(root, 0700); err != nil { 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) { offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64) if err != nil || offset < 0 { - writeError(w, 400, "上传偏移无效") + writeError(w, 400, message(w, "invalid_upload_offset_3bdbf1")) return } 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")) if err != nil { return err } 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) if err != nil { @@ -143,22 +143,22 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request) return err } 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) 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)) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { - return fmt.Errorf("%w:%w", errMaintenanceInput, err) + return i18n.New("w_w_f0fd7d", errMaintenanceInput, err) } return err } 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 { 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) { 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 { @@ -200,7 +200,7 @@ func (s *Server) validateResources(stage string) error { } for _, entry := range entries { if !entry.IsDir() { - return fmt.Errorf("%w:资源目录内包含非目录条目", errMaintenanceInput) + return i18n.New("w_the_resource_directory_contains_3898a5", errMaintenanceInput) } switch kind { case "fonts": @@ -227,7 +227,7 @@ func (s *Server) validateResources(stage string) error { func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Request) { var manifest backup.Manifest 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")) if err != nil { return err @@ -237,7 +237,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques return err } if info.Size() != meta.Size { - return fmt.Errorf("%w:上传尚未完成", errMaintenanceInput) + return i18n.New("w_upload_is_not_complete_8ef575", errMaintenanceInput) } meta.Validated = false if err = saveUpload(dir, meta); err != nil { @@ -255,12 +255,12 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques return err } 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 { bytes += entry.Size 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 { @@ -284,7 +284,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques } else { var databaseError *sqlite.Error 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()) @@ -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}) } 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")) if err != nil { return err @@ -326,20 +326,20 @@ func (s *Server) restoreInstance(w http.ResponseWriter, r *http.Request) { return } if !body.Confirmed { - writeError(w, 400, "请先确认替换实例数据并重新登录") + writeError(w, 400, message(w, "confirm_replacing_instance_data_and_52f3ca")) return } if s.Restart == nil { - writeError(w, 503, "当前服务不支持实例恢复") + writeError(w, 503, message(w, "this_service_does_not_support_a9a8ba")) return } - err := s.maintain("恢复完整实例", func() error { + err := s.maintain("restore_full_instance_9b4ee0", func() error { meta, dir, err := s.readUpload(r, body.ID) if err != nil { return err } 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") if err != nil { @@ -367,7 +367,7 @@ type cleanupResult struct { func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) { result := cleanupResult{Failed: []string{}} - err := s.maintain("清理临时文件", func() error { + err := s.maintain("clean_temporary_files_af91ae", func() error { // temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。 temp, err := s.tempDir() if err != nil { @@ -435,7 +435,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) { e = os.RemoveAll(target) } if e != nil { - result.Failed = append(result.Failed, e.Error()) + result.Failed = append(result.Failed, i18n.RenderError(e, responseLocale(w))) } else { result.Files += count result.Bytes += size @@ -454,5 +454,5 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) { func (s *Server) SetRestoreFailure(err error) { s.maintenanceStatusMu.Lock() 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()} } diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 84a0113..68dba55 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "RawMark API", "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": [ { diff --git a/internal/api/plugins.go b/internal/api/plugins.go index 9ecbabf..c1f692a 100644 --- a/internal/api/plugins.go +++ b/internal/api/plugins.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "io" "io/fs" "mime" @@ -15,6 +14,7 @@ import ( "os" "path" "path/filepath" + "rawmark/internal/i18n" "regexp" "strings" @@ -48,7 +48,7 @@ func validPluginID(id string) bool { func parsePluginManifest(data []byte) (pluginManifest, error) { 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 { - return m, fmt.Errorf("插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1") + return m, i18n.New("invalid_plugin_manifest_a_valid_121d94") } return m, nil } @@ -82,14 +82,14 @@ func (s *Server) openPlugin(id string) (*os.Root, pluginInfo, error) { return nil, info, err } 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") if err != nil { return nil, info, err } 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) ok = true @@ -106,7 +106,7 @@ func (s *Server) enabledPlugins() (map[string]bool, error) { err = json.Unmarshal([]byte(value), &enabled) } if enabled == nil && err == nil { - err = fmt.Errorf("插件启用配置无效") + err = i18n.New("invalid_plugin_enabled_configuration_26d4bb") } 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) { if errors.Is(err, fs.ErrNotExist) { - writeError(w, 404, "插件不存在或安装不完整") + writeError(w, 404, message(w, "plugin_not_found_or_installation_f577c6")) } else { internalError(w, r, err) } @@ -172,28 +172,28 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) { var info pluginInfo archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { - return info, fmt.Errorf("无法读取插件 ZIP") + return info, i18n.New("could_not_read_plugin_zip_922fd9") } if len(archive.File) > 128 { - return info, fmt.Errorf("插件最多包含 128 个文件或目录") + return info, i18n.New("a_plugin_may_contain_at_41c105") } seen := map[string]bool{} var total int64 for _, file := range archive.File { 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()) { - return info, fmt.Errorf("插件包含非法路径、重复路径或特殊文件") + return info, i18n.New("plugin_contains_invalid_paths_duplicate_6a1e05") } seen[name] = true if file.FileInfo().IsDir() { continue } 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() 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)) closeErr := src.Close() @@ -205,7 +205,7 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) { } total += int64(len(content)) if total > maxPluginExpanded { - return info, fmt.Errorf("插件解压后超过 20 MiB") + return info, i18n.New("expanded_plugin_exceeds_mib_e06d7b") } if name == "manifest.json" { info.pluginManifest, err = parsePluginManifest(content) @@ -222,11 +222,11 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) { } } 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")) 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) 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) { if s.PluginsDir == "" { - writeError(w, 503, "插件目录未配置") + writeError(w, 503, message(w, "plugin_directory_is_not_configured_5e0928")) return } r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10)) 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 } defer r.MultipartForm.RemoveAll() file, _, err := r.FormFile("file") if err != nil { - writeError(w, 400, "请选择插件 ZIP 文件") + writeError(w, 400, message(w, "choose_a_plugin_zip_file_7360c5")) return } defer file.Close() @@ -257,7 +257,7 @@ func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) { return } if len(data) > maxPluginZip { - writeError(w, 413, "插件 ZIP 超过 10 MiB") + writeError(w, 413, message(w, "plugin_zip_exceeds_mib_046e37")) return } s.pluginsMu.Lock() @@ -274,12 +274,12 @@ func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) { defer os.RemoveAll(stage) info, err := unpackPlugin(data, stage) if err != nil { - writeError(w, 400, err.Error()) + writeError(w, 400, i18n.RenderError(err, responseLocale(w))) return } target := filepath.Join(s.PluginsDir, info.ID) if _, err := os.Lstat(target); err == nil { - writeError(w, 409, "同名插件已安装,请先卸载") + writeError(w, 409, message(w, "a_plugin_with_this_id_1551cb")) return } else if !errors.Is(err, fs.ErrNotExist) { internalError(w, r, err) @@ -305,7 +305,7 @@ func (s *Server) togglePlugin(w http.ResponseWriter, r *http.Request) { return } if body.Enabled == nil { - writeError(w, 400, "必须指定 enabled") + writeError(w, 400, message(w, "enabled_must_be_specified_12873c")) return } s.pluginsMu.Lock() @@ -358,7 +358,7 @@ func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) { defer dir.Close() name := r.PathValue("path") 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 } file, err := dir.Open(name) @@ -373,7 +373,7 @@ func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) { return } if !stat.Mode().IsRegular() { - writeError(w, 404, "插件资源不存在") + writeError(w, 404, message(w, "plugin_resource_not_found_8e73f3")) return } w.Header().Set("X-Content-Type-Options", "nosniff") @@ -420,7 +420,7 @@ func (s *Server) putPluginData(w http.ResponseWriter, r *http.Request) { return } if len(body.Data) == 0 { - writeError(w, 400, "必须提供 data") + writeError(w, 400, message(w, "data_must_be_provided_0dc1bc")) return } s.pluginsMu.Lock() diff --git a/internal/api/protocol.go b/internal/api/protocol.go index 2090aae..8cd0c37 100644 --- a/internal/api/protocol.go +++ b/internal/api/protocol.go @@ -21,7 +21,7 @@ type response struct { } 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) { writeResponse(w, status, response{Code: code, Data: data, Message: message}) diff --git a/internal/api/resources.go b/internal/api/resources.go index 73725b4..82a33a6 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -24,7 +24,7 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { switch body.Kind { case "images", "fonts": if body.PluginID != "" || body.Revision != "" { - writeError(w, 400, "资源票据范围无效") + writeError(w, 400, message(w, "invalid_resource_ticket_scope_7abece")) return } case "events": @@ -40,13 +40,13 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { } dir.Close() if info.Revision != body.Revision { - writeError(w, 409, "插件版本已改变,请刷新") + writeError(w, 409, message(w, "plugin_version_changed_refresh_the_9fab75")) return } t.Resource, t.Revision = body.PluginID, body.Revision suffix = "plugins/" + body.PluginID + "/assets/" + body.Revision + "/" default: - writeError(w, 400, "资源票据用途无效") + writeError(w, 400, message(w, "invalid_resource_ticket_purpose_e266b8")) return } plain := opaqueToken() @@ -66,7 +66,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) { return } if !ok { - writeError(w, 401, "资源票据已失效") + writeError(w, 401, message(w, "resource_ticket_has_expired_309b64")) return } parent := principal{SessionHash: t.SessionHash, ExpiresAt: t.ExpiresAt} @@ -76,7 +76,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) { return } if !valid { - writeError(w, 401, "登录已失效") + writeError(w, 401, message(w, "login_session_has_expired_075903")) return } r = withIdentity(r, parent) @@ -84,14 +84,14 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) { switch { case kind == "images" && t.Kind == "images": if !vault.IsImage(name) { - writeError(w, 403, "票据仅允许读取图片") + writeError(w, 403, message(w, "this_ticket_only_permits_reading_f26030")) return } s.raw(w, r) case kind == "fonts" && t.Kind == "fonts": id, ok := strings.CutSuffix(name, "/file") if !ok || strings.Contains(id, "/") { - writeError(w, 403, "字体票据范围不匹配") + writeError(w, 403, message(w, "font_ticket_scope_does_not_95339a")) return } r.SetPathValue("id", id) @@ -99,7 +99,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) { case kind == "plugins" && t.Kind == "plugin": parts := strings.SplitN(name, "/", 4) 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 } 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 == "": s.events(w, r) default: - writeError(w, 403, "资源票据用途不匹配") + writeError(w, 403, message(w, "resource_ticket_purpose_does_not_1f573e")) } } diff --git a/internal/api/settings.go b/internal/api/settings.go index 8b1cf55..155b52e 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "rawmark/internal/i18n" "rawmark/internal/auth" "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) { data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes)) if err != nil { - writeError(w, 413, "设置过大") + writeError(w, 413, message(w, "settings_are_too_large_8a580f")) return } var patch map[string]json.RawMessage 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 } s.settingsMu.Lock() @@ -60,8 +61,12 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) { settings[k] = v } } + if err := validateLocale(settings); err != nil { + writeError(w, 400, i18n.RenderError(err, responseLocale(w))) + return + } if _, err := trashRetentionDays(settings); err != nil { - writeError(w, 400, err.Error()) + writeError(w, 400, i18n.RenderError(err, responseLocale(w))) return } merged, err := json.Marshal(settings) @@ -70,7 +75,7 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) { return } if len(merged) > maxJSONBytes { - writeError(w, 413, "设置过大") + writeError(w, 413, message(w, "settings_are_too_large_8a580f")) return } 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); { 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): - writeFailure(w, 400, CodeCurrentPassword, "当前密码错误", nil) + writeFailure(w, 400, CodeCurrentPassword, message(w, "current_password_is_incorrect_e0f0aa"), nil) case errors.Is(err, auth.ErrWeakPassword): - writeError(w, 400, err.Error()) + writeError(w, 400, i18n.RenderError(err, responseLocale(w))) case err != nil: internalError(w, r, err) default: diff --git a/internal/api/static.go b/internal/api/static.go index 6eeef5a..b1449ce 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -38,13 +38,13 @@ func newStaticHandler(fsys fs.FS) http.Handler { func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { - writeError(w, 405, "不支持的请求方法") + writeError(w, 405, message(w, "unsupported_request_method_bc2f08")) return } name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") // 已移除的公开入口不能回退到应用首页。 if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" { - writeError(w, 404, "页面不存在") + writeError(w, 404, message(w, "page_not_found_e75737")) return } f, err := h.load(name) @@ -52,7 +52,7 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 前端路由:未知路径一律返回入口页面 name = "index.html" if f, err = h.load(name); err != nil { - writeError(w, 404, "前端资源缺失") + writeError(w, 404, message(w, "frontend_resources_are_missing_2c9b3e")) return } } diff --git a/internal/api/storage.go b/internal/api/storage.go index 164eff3..bcc3c5f 100644 --- a/internal/api/storage.go +++ b/internal/api/storage.go @@ -3,10 +3,10 @@ package api import ( "context" "errors" - "fmt" "io/fs" "net/http" "path/filepath" + "rawmark/internal/i18n" "strings" "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) { usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}} if dataDir == "" { - return usage, fmt.Errorf("未配置应用数据目录") + return usage, i18n.New("application_data_directory_is_not_a1f183") } dataRoot, err := filepath.EvalSymlinks(dataDir) if err != nil { diff --git a/internal/api/themes.go b/internal/api/themes.go index f707516..7a8d3eb 100644 --- a/internal/api/themes.go +++ b/internal/api/themes.go @@ -4,11 +4,11 @@ import ( _ "embed" "encoding/json" "errors" - "fmt" "io" "io/fs" "net/http" "os" + "rawmark/internal/i18n" "regexp" ) @@ -54,14 +54,14 @@ func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) { return nil, err } 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)) if err != nil { return nil, err } if int64(len(data)) > limit { - return nil, fmt.Errorf("主题文件过大") + return nil, i18n.New("theme_file_is_too_large_08667a") } return data, nil } @@ -89,19 +89,21 @@ func (s *Server) readTheme(id string) (themeManifest, string, error) { return manifest, "", err } 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 == "" { - 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) return manifest, string(css), err } 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 { - themes = append(themes, theme.themeManifest) + manifest := theme.themeManifest + manifest.Name = message(w, "theme_"+theme.ID) + themes = append(themes, manifest) } if 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) { manifest, css, err := s.readTheme(r.PathValue("id")) if errors.Is(err, fs.ErrNotExist) { - writeError(w, 404, "主题不存在或文件不完整") + writeError(w, 404, message(w, "theme_not_found_or_files_3b0d5b")) return } if err != nil { internalError(w, r, err) return } + if _, _, ok := builtinTheme(manifest.ID); ok { + manifest.Name = message(w, "theme_"+manifest.ID) + } writeJSON(w, 200, struct { themeManifest CSS string `json:"css"` diff --git a/internal/api/tokens.go b/internal/api/tokens.go index b19715b..b1f2006 100644 --- a/internal/api/tokens.go +++ b/internal/api/tokens.go @@ -4,6 +4,7 @@ import ( _ "embed" "errors" "net/http" + "rawmark/internal/i18n" "strings" "rawmark/internal/auth" @@ -26,12 +27,12 @@ func (s *Server) createToken(w http.ResponseWriter, r *http.Request) { } name := strings.TrimSpace(body.Name) if name == "" || len([]rune(name)) > 100 { - writeError(w, 400, "名称不能为空且不超过 100 个字符") + writeError(w, 400, message(w, "name_must_contain_between_and_7c157b")) return } plain, t, err := s.auth.CreateAPIToken(name, body.Scope) if errors.Is(err, auth.ErrBadScope) { - writeError(w, 400, err.Error()) + writeError(w, 400, i18n.RenderError(err, responseLocale(w))) return } if err != nil { @@ -48,7 +49,7 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) { return } if !ok { - writeError(w, 404, "令牌不存在") + writeError(w, 404, message(w, "token_not_found_da59eb")) return } writeJSON(w, 200, map[string]bool{"ok": true}) diff --git a/internal/api/trash.go b/internal/api/trash.go index 4e0d783..d57d733 100644 --- a/internal/api/trash.go +++ b/internal/api/trash.go @@ -4,9 +4,9 @@ import ( "bytes" "context" "encoding/json" - "fmt" "log/slog" "net/http" + "rawmark/internal/i18n" "time" ) @@ -20,7 +20,7 @@ func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) { } var days int64 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 } @@ -31,6 +31,9 @@ func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) { internalError(w, r, err) return } + for i := range result.Failed { + result.Failed[i].Error = i18n.RenderError(result.Failed[i].Cause, responseLocale(w)) + } writeJSON(w, http.StatusOK, result) } @@ -53,7 +56,7 @@ func (s *Server) cleanupTrash(now time.Time) error { return err } 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 { slog.Info("已清理过期回收站条目", "count", len(result.Deleted)) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 00d659d..b39c131 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -7,8 +7,8 @@ import ( "crypto/subtle" "encoding/base64" "encoding/hex" - "errors" "fmt" + "rawmark/internal/i18n" "strings" "sync" "time" @@ -38,11 +38,11 @@ const ( ) var ( - ErrAlreadySetUp = errors.New("已完成初始化") - ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen) - ErrBadCredentials = errors.New("密码错误") - ErrRateLimited = errors.New("尝试次数过多,请稍后再试") - ErrBadScope = errors.New("权限范围只能是 read 或 write") + ErrAlreadySetUp = i18n.New("setup_is_already_complete_222ec6") + ErrWeakPassword = i18n.New("password_must_contain_at_least_decf04", MinPasswordLen) + ErrBadCredentials = i18n.New("incorrect_password_4585c8") + ErrRateLimited = i18n.New("too_many_attempts_please_try_8bbf6d") + ErrBadScope = i18n.New("scope_must_be_read_or_2cb466") ) type Manager struct { @@ -240,13 +240,13 @@ func hashPassword(password string) string { func verifyPassword(encoded, password string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { - return false, errors.New("密码哈希格式无法识别") + return false, i18n.New("unrecognized_password_hash_format_2f5457") } var mem uint32 var t uint32 var p uint8 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 salt, err := b64.DecodeString(parts[4]) diff --git a/internal/backup/archive.go b/internal/backup/archive.go index 35ddb54..dddb4d0 100644 --- a/internal/backup/archive.go +++ b/internal/backup/archive.go @@ -9,11 +9,11 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "io" "os" "path" "path/filepath" + "rawmark/internal/i18n" "strings" "syscall" "time" @@ -24,7 +24,7 @@ const MaxExpandedBytes int64 = 8 << 30 const MaxEntries = 100000 // ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。 -var ErrInvalidArchive = errors.New("备份内容无效") +var ErrInvalidArchive = i18n.New("invalid_backup_contents_ecff07") type Entry struct { Path string `json:"path"` @@ -62,7 +62,7 @@ func validEntry(kind, name string) bool { // Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。 func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) { 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) if err != nil { @@ -101,7 +101,7 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func( return nil } if !validEntry(kind, name) || names[name] { - return fmt.Errorf("备份路径无效或重复: %s", name) + return i18n.New("invalid_or_duplicate_backup_path_ce9c5b", name) } names[name] = true info, err := d.Info() @@ -109,11 +109,11 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func( return err } 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()} if len(manifest.Entries) >= MaxEntries { - return fmt.Errorf("备份文件数超过上限") + return i18n.New("backup_file_count_exceeds_the_77ac37") } h, err := zip.FileInfoHeader(info) if err != nil { @@ -141,17 +141,17 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func( } total += e.Size if total > MaxExpandedBytes { - return fmt.Errorf("备份展开大小超过 8 GiB") + return i18n.New("expanded_backup_exceeds_gib_b38d18") } 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) if err != nil { return err } 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)) } @@ -177,7 +177,7 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func( return err } if info.Size() > MaxArchiveBytes { - return fmt.Errorf("备份压缩大小超过 2 GiB") + return i18n.New("compressed_backup_exceeds_gib_3962d5") } return f.Sync() } @@ -188,52 +188,52 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) { z, err := zip.OpenReader(archive) if errors.Is(err, zip.ErrInsecurePath) { 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) { - return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err) + return manifest, i18n.New("w_w_f0fd7d", ErrInvalidArchive, err) } if err != nil { return manifest, err } defer z.Close() 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{} for _, f := range z.File { name := strings.TrimSuffix(f.Name, "/") 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() { - return manifest, fmt.Errorf("%w:归档不允许符号链接或特殊文件", ErrInvalidArchive) + return manifest, i18n.New("w_archives_cannot_contain_symlinks_fd836b", ErrInvalidArchive) } entries[name] = f } m := entries["manifest.json"] 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() 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) if err != nil { - err = fmt.Errorf("%w:%w", ErrInvalidArchive, err) + err = i18n.New("w_w_f0fd7d", ErrInvalidArchive, err) } err = errors.Join(err, r.Close()) if err != nil { return manifest, err } 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 for _, e := range manifest.Entries { 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 } @@ -242,7 +242,7 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) { return manifest, err } 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{} var total int64 @@ -252,17 +252,17 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) { } 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 { - return manifest, fmt.Errorf("%w:备份清单与文件不符", ErrInvalidArchive) + return manifest, i18n.New("w_backup_manifest_does_not_69c8ba", ErrInvalidArchive) } seen[e.Path] = true total += e.Size 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)) if e.Dir { 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 { return manifest, err @@ -270,14 +270,14 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) { continue } 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 { return manifest, err } in, err := f.Open() 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) 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)) var corruptDeflate flate.CorruptInputError 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()) if err != nil { return manifest, err } 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) 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()) { - 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()) { - return manifest, fmt.Errorf("%w:设置备份缺少设置", ErrInvalidArchive) + return manifest, i18n.New("w_settings_backup_is_missing_46056b", ErrInvalidArchive) } return manifest, nil } diff --git a/internal/backup/log.go b/internal/backup/log.go index d646c3b..c84a865 100644 --- a/internal/backup/log.go +++ b/internal/backup/log.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "rawmark/internal/i18n" "sync" ) @@ -35,7 +36,7 @@ func (l *LogWriter) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() 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 err := l.file.Close(); err != nil { diff --git a/internal/backup/restore.go b/internal/backup/restore.go index 3d4bb23..72ad2de 100644 --- a/internal/backup/restore.go +++ b/internal/backup/restore.go @@ -5,10 +5,10 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "io" "os" "path/filepath" + "rawmark/internal/i18n" "strings" "rawmark/internal/store" @@ -78,14 +78,14 @@ func CheckDatabase(file string) error { header := make([]byte, 16) _, err = io.ReadFull(f, header) 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()) if err != nil { return err } 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) if err != nil { @@ -101,7 +101,7 @@ func CheckDatabase(file string) error { return err } } - return fmt.Errorf("%w:%w", ErrInvalidArchive, err) + return i18n.New("w_w_f0fd7d", ErrInvalidArchive, err) } password, ok, err := st.Setting("auth.password") if err != nil { @@ -109,15 +109,15 @@ func CheckDatabase(file string) error { } parts := strings.Split(password, "$") 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]) 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]) if err != nil || len(key) != 32 { - return fmt.Errorf("%w:备份密码校验值无效", ErrInvalidArchive) + return i18n.New("w_invalid_backup_password_verification_17d1c1", ErrInvalidArchive) } return nil } @@ -183,7 +183,7 @@ func CopyTree(src, dest string) error { return err } if !info.Mode().IsRegular() { - return fmt.Errorf("不允许复制特殊文件") + return i18n.New("special_files_cannot_be_copied_441836") } if err = copyFile(p, target, 0600); err != nil { return err @@ -272,17 +272,17 @@ func Recover(data, vault string) (err error) { return err } 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)) { - return fmt.Errorf("恢复记录路径无效") + return i18n.New("invalid_restore_record_path_611881") } if record.Kind == "settings" { return recoverSettings(data, record) } if record.State != "prepared" { - err = fmt.Errorf("上次实例恢复被中断") + err = i18n.New("the_previous_instance_restore_was_a9d480") } if record.State == "prepared" { record.State = "applying" @@ -304,13 +304,13 @@ func Recover(data, vault string) (err error) { restoreErr = apply(data, vault, stage) } 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 { return errors.Join(err, removeErr) } if err != nil { - return fmt.Errorf("导入恢复失败,已回退原实例: %w", err) + return i18n.New("restore_failed_original_instance_restored_c03846", err) } return nil } @@ -329,7 +329,7 @@ func FinishRecovery(data string) error { return err } 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 { return err @@ -383,5 +383,5 @@ func recoverSettings(data string, record RestoreRecord) error { if err = os.Remove(recordPath(data)); err != nil { return err } - return fmt.Errorf("设置导入被中断,已回退原设置和资源") + return i18n.New("settings_import_was_interrupted_original_417ef5") } diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..5730aec --- /dev/null +++ b/internal/i18n/i18n.go @@ -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() +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..d4147eb --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -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") + } +} diff --git a/internal/i18n/messages.json b/internal/i18n/messages.json new file mode 100644 index 0000000..404f02b --- /dev/null +++ b/internal/i18n/messages.json @@ -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" + } +} diff --git a/internal/search/search.go b/internal/search/search.go index 90eec67..98436cb 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log/slog" + "rawmark/internal/i18n" "strings" "sync" "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{}} if err := x.init(); err != nil { db.Close() - return nil, fmt.Errorf("初始化搜索索引失败: %w", err) + return nil, i18n.New("could_not_initialize_search_index_9155c3", err) } return x, nil } diff --git a/internal/store/backup.go b/internal/store/backup.go index 941d76e..151e9e0 100644 --- a/internal/store/backup.go +++ b/internal/store/backup.go @@ -4,9 +4,8 @@ import ( "context" "database/sql" "encoding/json" - "errors" - "fmt" "net/url" + "rawmark/internal/i18n" "strings" ) @@ -28,7 +27,7 @@ func (s *Store) Preferences() (map[string]json.RawMessage, error) { return nil, err } 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) } @@ -47,7 +46,7 @@ func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error { } for key, value := range values { 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 { return err @@ -68,7 +67,7 @@ func (s *Store) CheckBackup() error { return err } 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`} { rows, err := s.db.Query(query) diff --git a/internal/store/store.go b/internal/store/store.go index cdbbc13..c23aa39 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -4,7 +4,7 @@ package store import ( "database/sql" "errors" - "fmt" + "rawmark/internal/i18n" "time" _ "modernc.org/sqlite" @@ -48,7 +48,7 @@ func Open(path string) (*Store, error) { } if _, err := db.Exec(schema); err != nil { db.Close() - return nil, fmt.Errorf("初始化数据库失败: %w", err) + return nil, i18n.New("could_not_initialize_database_w_208f9e", err) } return &Store{db: db}, nil } diff --git a/internal/vault/cleanup.go b/internal/vault/cleanup.go index 3d4f614..763ff71 100644 --- a/internal/vault/cleanup.go +++ b/internal/vault/cleanup.go @@ -1,6 +1,6 @@ package vault -import "fmt" +import "rawmark/internal/i18n" type CleanupResult struct { Deleted []TrashItem `json:"deleted"` @@ -9,6 +9,7 @@ type CleanupResult struct { type CleanupSkip struct { Path string `json:"path"` Reason string `json:"reason"` + Cause error `json:"-"` } // CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。 @@ -31,7 +32,7 @@ func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) { } metadata := Analyze(entry.Path, content) 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 { references[ref.Target] = true @@ -43,27 +44,27 @@ func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) { continue } seen[p] = true - reason := "" + var reason error switch { case !IsImage(p): - reason = "不是图片文件" + reason = i18n.New("image_not_file") case references[p]: - reason = "仍被笔记引用" + reason = i18n.New("image_in_use") default: info, err := v.Stat(p) if err != nil { - reason = err.Error() + reason = err } else if !info.Mode().IsRegular() { - reason = "不是图片文件" + reason = i18n.New("image_not_file") } } - if reason != "" { - result.Skipped = append(result.Skipped, CleanupSkip{p, reason}) + if reason != nil { + result.Skipped = append(result.Skipped, CleanupSkip{p, reason.Error(), reason}) continue } item, err := v.deleteLocked(p) if err != nil { - result.Skipped = append(result.Skipped, CleanupSkip{p, err.Error()}) + result.Skipped = append(result.Skipped, CleanupSkip{p, err.Error(), err}) continue } result.Deleted = append(result.Deleted, item) diff --git a/internal/vault/export.go b/internal/vault/export.go index 9ef335a..5a4898d 100644 --- a/internal/vault/export.go +++ b/internal/vault/export.go @@ -3,13 +3,13 @@ package vault import ( "archive/zip" "context" - "fmt" "html" "io" "net/url" "os" "path" "path/filepath" + "rawmark/internal/i18n" "sort" "strings" ) @@ -18,7 +18,7 @@ import ( // ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。 func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) { if len(paths) == 0 { - return nil, "", fmt.Errorf("%w:请选择导出内容", ErrInvalidPath) + return nil, "", i18n.New("w_choose_content_to_export_170d5e", ErrInvalidPath) } v.mu.Lock() 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) 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 == "" { continue @@ -80,11 +80,11 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na continue } 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) if e != nil { - return nil, "", fmt.Errorf("附件 %s:%w", rel, e) + return nil, "", i18n.New("attachment_s_w_7bc269", rel, e) } if !info.IsDir() { files[rel] = false diff --git a/internal/vault/images.go b/internal/vault/images.go index 0c41323..30a2426 100644 --- a/internal/vault/images.go +++ b/internal/vault/images.go @@ -3,20 +3,20 @@ package vault import ( "bytes" "errors" - "fmt" "html" "io" "io/fs" "os" "path" "path/filepath" + "rawmark/internal/i18n" "strings" ) // ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。 func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error { 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) if err != nil { @@ -30,7 +30,7 @@ func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) err return ErrTooLarge } if len(data) == 0 { - return fmt.Errorf("%w:图片不能为空", ErrInvalidPath) + return i18n.New("w_image_cannot_be_empty_a9a272", ErrInvalidPath) } v.mu.Lock() defer v.mu.Unlock() diff --git a/internal/vault/import.go b/internal/vault/import.go index b65ea49..d831715 100644 --- a/internal/vault/import.go +++ b/internal/vault/import.go @@ -6,12 +6,12 @@ import ( "compress/flate" "context" "errors" - "fmt" "io" "io/fs" "os" "path" "path/filepath" + "rawmark/internal/i18n" "sort" "strings" "syscall" @@ -26,7 +26,7 @@ const ( importAssetLimit = 50 << 20 ) -var ErrInvalidImport = errors.New("导入内容无效") +var ErrInvalidImport = i18n.New("invalid_import_contents_ed556a") // ImportFile 的读取范围是单个上传文件,ZIP 与普通 Markdown 共用这一输入边界。 type ImportFile struct { @@ -36,25 +36,26 @@ type ImportFile struct { } type ImportResult struct { - Paths []string `json:"paths"` - Notes int `json:"notes"` - Assets int `json:"assets"` - Warnings []string `json:"warnings"` + Paths []string `json:"paths"` + Notes int `json:"notes"` + Assets int `json:"assets"` + Warnings []string `json:"warnings"` + WarningMessages []*i18n.Error `json:"-"` } // Import 在隐藏目录内整理完整内容,提交前统一检查冲突,绝不覆盖已有文件。 func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) { result = ImportResult{Paths: []string{}, Warnings: []string{}} 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") if zipped { 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 != "" { - return result, fmt.Errorf("%w:普通 Markdown 不使用外层文件夹", ErrInvalidImport) + return result, i18n.New("w_plain_markdown_files_do_08b920", ErrInvalidImport) } if dir != "" { if _, err := CleanPath(dir); err != nil { @@ -74,7 +75,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi } defer func() { 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} @@ -87,30 +88,30 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return err } 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 { - return fmt.Errorf("%w:存在重复路径", ErrInvalidImport) + return i18n.New("w_duplicate_path_408694", ErrInvalidImport) } entries[name] = struct{}{} dest := filepath.Join(stage, filepath.FromSlash(name)) if isDir { err := os.MkdirAll(dest, 0o755) 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 } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { 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 } f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { 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 } @@ -131,7 +132,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi if zipped { z, err := zip.NewReader(files[0].Reader, files[0].Size) 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 { return result, ErrTooLarge @@ -141,19 +142,19 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi continue } // 本程序笔记导出的缺失附件报告,不作为笔记导入。 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, "/") r, err := f.Open() 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 = errors.Join(err, r.Close()) if err != nil { var corrupt flate.CorruptInputError 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 } @@ -161,7 +162,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi } else { for _, f := range files { 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 { return result, err @@ -169,7 +170,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi } } 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) 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), "/") { 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) @@ -187,14 +188,17 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, err } 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}) updated, warnings, err := staged.importLinks(ctx, note, content, &total, migrated) if err != nil { 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 { return result, ErrTooLarge } @@ -213,7 +217,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi } metadata := Analyze(note, body) 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 { used[ref.Target] = true @@ -275,7 +279,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, err } if !info.IsDir() { - return result, fmt.Errorf("%w:目标不是文件夹", ErrInvalidImport) + return result, i18n.New("w_destination_is_not_a_f9de1c", ErrInvalidImport) } for _, target := range targets { dest, err := v.abs(target) @@ -283,7 +287,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, err } 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) { 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 { for _, prev := range published { 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 diff --git a/internal/vault/import_links.go b/internal/vault/import_links.go index b91d0bc..d95e630 100644 --- a/internal/vault/import_links.go +++ b/internal/vault/import_links.go @@ -8,9 +8,9 @@ import ( "os" "path" "path/filepath" + "rawmark/internal/i18n" "regexp" "sort" - "strconv" "strings" ) @@ -228,7 +228,7 @@ func importLinkSpans(content []byte) []importLink { 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{} replacements := map[string]string{} spans := importLinkSpans(content) @@ -312,9 +312,9 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to last = span.end } updated := append(out, content[last:]...) - warnings := []string{} + warnings := []*i18n.Error{} 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 } diff --git a/internal/vault/metadata.go b/internal/vault/metadata.go index 8329334..053e091 100644 --- a/internal/vault/metadata.go +++ b/internal/vault/metadata.go @@ -2,10 +2,10 @@ package vault import ( "bytes" - "fmt" "html" "net/url" "path" + "rawmark/internal/i18n" "regexp" "sort" "strings" @@ -22,11 +22,12 @@ type Reference struct { } type Metadata struct { - Title string `json:"title"` - Tags []string `json:"tags"` - Links []Reference `json:"links"` - Assets []Reference `json:"assets"` - Warnings []string `json:"warnings"` + Title string `json:"title"` + Tags []string `json:"tags"` + Links []Reference `json:"links"` + Assets []Reference `json:"assets"` + Warnings []string `json:"warnings"` + Diagnostics []*i18n.Error `json:"-"` } 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 提取语义关系而不重新序列化正文;源位置保留为原文行号。 func Analyze(note string, content []byte) Metadata { 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{} addTag := func(s string) { s = strings.TrimPrefix(strings.TrimSpace(s), "#") @@ -68,7 +74,7 @@ func Analyze(note string, content []byte) Metadata { offset += len(lines[i]) } if end < 0 { - m.Warnings = append(m.Warnings, "YAML 元数据未闭合") + addWarning("metadata_unclosed") end = len(source) } else { var front struct { @@ -76,7 +82,7 @@ func Analyze(note string, content []byte) Metadata { Title string `yaml:"title"` } 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 { if front.Title != "" { m.Title = front.Title @@ -89,18 +95,18 @@ func Analyze(note string, content []byte) Metadata { addTag(tag) } } else { - m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表") + addWarning("metadata_tags") } case yaml.SequenceNode: for _, tag := range front.Tags.Content { if tag.Kind != yaml.ScalarNode || tag.Tag != "!!str" { - m.Warnings = append(m.Warnings, "YAML tags 必须是字符串列表") + addWarning("metadata_tag_list") break } addTag(tag.Value) } default: - m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表") + addWarning("metadata_tags") } } } diff --git a/internal/vault/shared_images.go b/internal/vault/shared_images.go index 400374e..8a005a7 100644 --- a/internal/vault/shared_images.go +++ b/internal/vault/shared_images.go @@ -1,10 +1,10 @@ package vault import ( - "fmt" "os" "path" "path/filepath" + "rawmark/internal/i18n" "strings" ) @@ -25,7 +25,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ } metadata := Analyze(from, content) 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 for _, ref := range metadata.Assets { @@ -51,7 +51,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ } m := Analyze(entry.Path, body) 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 { used[ref.Target] = true diff --git a/internal/vault/trash.go b/internal/vault/trash.go index 430a0ed..b4b9b29 100644 --- a/internal/vault/trash.go +++ b/internal/vault/trash.go @@ -3,9 +3,9 @@ package vault import ( "encoding/json" "errors" - "fmt" "os" "path/filepath" + "rawmark/internal/i18n" "time" ) @@ -39,6 +39,7 @@ func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error { type TrashFailure struct { ID string `json:"id"` Error string `json:"error"` + Cause error `json:"-"` } type TrashPurgeResult struct { @@ -53,7 +54,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) defer v.mu.Unlock() result := TrashPurgeResult{Deleted: []string{}, Failed: []TrashFailure{}} if days < 0 { - return result, fmt.Errorf("保留天数不能为负数") + return result, i18n.New("retention_days_cannot_be_negative_f37c9b") } dirs, err := os.ReadDir(v.trash) if err != nil { @@ -72,7 +73,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) err = v.purgeTrashItemLocked(metadata) } 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 { result.Deleted = append(result.Deleted, id) } diff --git a/internal/vault/tree_icons.go b/internal/vault/tree_icons.go index c72a929..08067c3 100644 --- a/internal/vault/tree_icons.go +++ b/internal/vault/tree_icons.go @@ -3,10 +3,10 @@ package vault import ( "encoding/json" "errors" - "fmt" "io/fs" "os" "path/filepath" + "rawmark/internal/i18n" "regexp" "strings" "unicode/utf8" @@ -18,7 +18,7 @@ type TreeIcon struct { 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 " @@ -46,14 +46,14 @@ func (v *Vault) readTreeIcons() (map[string]TreeIcon, error) { } var icons map[string]TreeIcon 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 { - return nil, errors.New("文件树图标元数据必须是对象") + return nil, i18n.New("file_tree_icon_metadata_must_54f104") } for p, icon := range icons { 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 diff --git a/internal/vault/vault.go b/internal/vault/vault.go index f6ce1b9..c2e118d 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -15,6 +15,7 @@ import ( "os" "path" "path/filepath" + "rawmark/internal/i18n" "regexp" "sort" "strings" @@ -23,16 +24,16 @@ import ( ) var ( - ErrInvalidPath = errors.New("非法路径") - ErrNotFound = errors.New("文件不存在") - ErrExists = errors.New("目标已存在") - ErrTooLarge = errors.New("文件过大") + ErrInvalidPath = i18n.New("invalid_path_ec93bf") + ErrNotFound = i18n.New("file_not_found_3703e4") + ErrExists = i18n.New("destination_already_exists_9511c3") + ErrTooLarge = i18n.New("file_is_too_large_5bfcab") ) // ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空) type ConflictError struct{ Rev string } -func (e *ConflictError) Error() string { return "版本冲突" } +func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() } const ( NoteExt = ".md" @@ -438,14 +439,14 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) { for _, image := range images { if image.removed { 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 { if image.created { 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 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] { 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 { - 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 { @@ -498,7 +499,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) { } if srcAssets != "" { 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 } @@ -580,13 +581,13 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) { rollbackFailed := false if assetsMoved { if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil { - err = errors.Join(err, fmt.Errorf("回滚回收站图片失败: %w", rollback)) + err = errors.Join(err, i18n.New("could_not_roll_back_trashed_b37d4f", rollback)) rollbackFailed = true } } if noteMoved { if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil { - err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback)) + err = errors.Join(err, i18n.New("could_not_roll_back_trashed_3758a4", rollback)) rollbackFailed = true } } @@ -607,7 +608,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) { noteMoved = true if item.Assets != "" { 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 != "" @@ -635,14 +636,14 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) { } var metadata trashMetadata 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))) { - return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据无效", id) + return trashMetadata{}, i18n.New("trash_item_s_has_invalid_006492", id) } for p, icon := range metadata.Icons { if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) { - return trashMetadata{}, fmt.Errorf("回收站条目 %s 图标元数据损坏", id) + return trashMetadata{}, i18n.New("trash_item_s_has_damaged_856237", id) } } return metadata, nil @@ -722,16 +723,16 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { } if assetsMoved { 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 { - 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 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 } @@ -774,7 +775,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri // SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。 func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) { if !IsImage(origName) { - return "", fmt.Errorf("%w:请选择图片文件", ErrInvalidPath) + return "", i18n.New("w_choose_an_image_file_544069", ErrInvalidPath) } if folder != "" { info, err := v.Stat(folder) diff --git a/internal/watch/watch.go b/internal/watch/watch.go index c0c8d95..72b8411 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -5,11 +5,11 @@ package watch import ( "context" "errors" - "fmt" "io/fs" "log/slog" "os" "path/filepath" + "rawmark/internal/i18n" "sort" "strings" "time" @@ -148,7 +148,7 @@ func (w *Watcher) scan() (map[string]fileState, error) { continue // 扫描期间被删除,下一轮会处理 } 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) } diff --git a/web/index.html b/web/index.html index 72e2d34..03cf99c 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + diff --git a/web/package.json b/web/package.json index a99d5a1..62e2ce7 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "pinia": "3.0.4", "turndown": "7.2.4", "vue": "3.5.44", + "vue-i18n": "11.4.16", "vue-router": "5.1.0" }, "devDependencies": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 170026a..dde210f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: vue: specifier: 3.5.44 version: 3.5.44(typescript@6.0.3) + vue-i18n: + specifier: 11.4.16 + version: 11.4.16(vue@3.5.44(typescript@6.0.3)) vue-router: specifier: 5.1.0 version: 5.1.0(@vue/compiler-sfc@3.5.44)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.44(typescript@6.0.3)))(rolldown@1.2.15)(vite@8.3.4(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.78.0)(yaml@2.9.1))(vue@3.5.44(typescript@6.0.3)) @@ -590,6 +593,22 @@ packages: '@iconify/utils@3.1.7': resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@intlify/core-base@11.4.16': + resolution: {integrity: sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==} + engines: {node: '>= 22'} + + '@intlify/devtools-types@11.4.16': + resolution: {integrity: sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@11.4.16': + resolution: {integrity: sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.16': + resolution: {integrity: sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==} + engines: {node: '>= 22'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1398,6 +1417,9 @@ packages: '@vue/compiler-ssr@3.5.44': resolution: {integrity: sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/devtools-api@7.7.10': resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} @@ -3561,6 +3583,12 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-i18n@11.4.16: + resolution: {integrity: sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==} + engines: {node: '>= 22'} + peerDependencies: + vue: ^3.0.0 + vue-router@5.1.0: resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} peerDependencies: @@ -4021,6 +4049,24 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@intlify/core-base@11.4.16': + dependencies: + '@intlify/devtools-types': 11.4.16 + '@intlify/message-compiler': 11.4.16 + '@intlify/shared': 11.4.16 + + '@intlify/devtools-types@11.4.16': + dependencies: + '@intlify/core-base': 11.4.16 + '@intlify/shared': 11.4.16 + + '@intlify/message-compiler@11.4.16': + dependencies: + '@intlify/shared': 11.4.16 + source-map-js: 1.2.2 + + '@intlify/shared@11.4.16': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4791,6 +4837,8 @@ snapshots: '@vue/compiler-dom': 3.5.44 '@vue/shared': 3.5.44 + '@vue/devtools-api@6.6.4': {} + '@vue/devtools-api@7.7.10': dependencies: '@vue/devtools-kit': 7.7.10 @@ -7143,6 +7191,14 @@ snapshots: transitivePeerDependencies: - supports-color + vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)): + 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(typescript@6.0.3) + vue-router@5.1.0(@vue/compiler-sfc@3.5.44)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.44(typescript@6.0.3)))(rolldown@1.2.15)(vite@8.3.4(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.78.0)(yaml@2.9.1))(vue@3.5.44(typescript@6.0.3)): dependencies: '@babel/generator': 8.0.6 diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest index a41b40e..cc4f5bd 100644 --- a/web/public/manifest.webmanifest +++ b/web/public/manifest.webmanifest @@ -2,7 +2,7 @@ "id": "/", "name": "RawMark", "short_name": "RawMark", - "lang": "zh-CN", + "lang": "en", "start_url": "/", "scope": "/", "display": "standalone", diff --git a/web/src/App.vue b/web/src/App.vue index 4f1a027..6426188 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,5 +1,8 @@