feat(i18n): add English and Simplified Chinese

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

No files matched your search

+49 -24
View File
@@ -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})
}
+5 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+4 -4
View File
@@ -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
}
}
+4 -4
View File
@@ -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
+13 -13
View File
@@ -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})
+13 -13
View File
@@ -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)
+7 -3
View File
@@ -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)
}
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"rawmark/internal/i18n"
"strings"
)
func localized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Language", string(i18n.Negotiate(r.Header.Get("Accept-Language"))))
}
next.ServeHTTP(w, r)
})
}
func responseLocale(w http.ResponseWriter) i18n.Locale {
return i18n.Negotiate(w.Header().Get("Content-Language"))
}
func message(w http.ResponseWriter, key string, args ...any) string {
return i18n.Text(responseLocale(w), key, args...)
}
func validateLocale(settings map[string]json.RawMessage) error {
if raw, ok := settings["locale"]; ok {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil
}
var locale string
if err := json.Unmarshal(raw, &locale); err != nil || (locale != "en" && locale != "zh-CN") {
return i18n.New("invalid_locale")
}
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"rawmark/internal/search"
"rawmark/internal/vault"
"reflect"
"strings"
"testing"
"time"
"rawmark/internal/i18n"
)
func localizedResponse(t *testing.T, res *http.Response) response {
t.Helper()
defer res.Body.Close()
var body response
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return body
}
func TestLocalizedHTTPContracts(t *testing.T) {
f := newFixture(t)
for _, language := range []string{"en", "zh-CN"} {
h := map[string]string{"Accept-Language": language}
res := f.do(newClient(), "GET", "/api/v1/settings", "", h)
if res.StatusCode != 401 || res.Header.Get("Content-Language") != language || res.Header.Get("Cache-Control") != "no-store" {
t.Fatal("authentication response contract changed")
}
body := localizedResponse(t, res)
want := "Please sign in"
if language == "zh-CN" {
want = "请先登录"
}
if body.Message != want || body.Code != 40100 {
t.Fatalf("%s: %+v", language, body)
}
res = f.do(f.admin, "PATCH", "/api/v1/settings", `{"locale":"fr"}`, h)
if res.StatusCode != 400 {
t.Fatal("invalid locale accepted")
}
if body := localizedResponse(t, res); body.Code != 40000 || body.Message != i18n.Text(i18n.Locale(language), "invalid_locale") {
t.Fatal(body)
}
id, _ := challengeFor(t, f)
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, "invalid", "password123"), h)
if res.StatusCode != 400 {
t.Fatal("invalid captcha status changed")
}
if body := localizedResponse(t, res); body.Code != CodeCaptchaInvalid || !strings.Contains(body.Message, map[string]string{"en": "Refresh", "zh-CN": "刷新"}[language]) {
t.Fatal(body)
}
id, code := challengeFor(t, f)
f.server.captchas.mu.Lock()
challenge := f.server.captchas.challenges[id]
challenge.expires = time.Now().Add(-time.Second)
f.server.captchas.challenges[id] = challenge
f.server.captchas.mu.Unlock()
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), h)
if body := localizedResponse(t, res); body.Code != CodeCaptchaExpired {
t.Fatal(body)
}
}
for _, value := range []string{`{"locale":"zh-CN"}`, `{"locale":"en"}`, `{"locale":null}`} {
res := f.do(f.admin, "PATCH", "/api/v1/settings", value, nil)
if res.StatusCode != 200 {
t.Fatal("valid language rejected")
}
res.Body.Close()
}
if err := validatePreferences(map[string]json.RawMessage{settingsKey: json.RawMessage(`{"locale":"fr"}`)}); !errors.Is(err, errMaintenanceInput) {
t.Fatal("invalid imported language accepted")
}
}
func TestMaintenanceStatusIsRenderedForEachReader(t *testing.T) {
f := newFixture(t)
if err := f.server.maintain("export_backup_e9d3dd", func() error { return i18n.New("invalid_locale") }); err == nil {
t.Fatal("missing failure")
}
for _, language := range []string{"en", "zh-CN"} {
data := decode[maintenanceStatus](t, f.do(f.admin, "GET", "/api/v1/maintenance/status", "", map[string]string{"Accept-Language": language}))
if data.Operation != i18n.Text(i18n.Locale(language), "export_backup_e9d3dd") || data.Error != i18n.Text(i18n.Locale(language), "invalid_locale") {
t.Fatal(data)
}
}
}
func TestLocalizedResultDetailsDoNotRewriteSourceData(t *testing.T) {
f := newFixture(t)
const content = "---\ntitle: User title\ntags: 1\n---\nUser content\n"
f.write("bad.md", content)
f.write("used.md", "![](./assets/used.png)")
f.write("assets/used.png", "synthetic image")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
h := map[string]string{"Accept-Language": language}
result := decode[struct {
Notes []struct {
Path string
Warnings []string
}
}](t, f.do(f.admin, "GET", "/api/v1/metadata", "", h))
found := false
for _, note := range result.Notes {
if note.Path == "bad.md" {
found = true
if len(note.Warnings) != 1 || note.Warnings[0] != i18n.Text(i18n.Locale(language), "metadata_tags") {
t.Fatal(note.Warnings)
}
}
}
if !found {
t.Fatal("note missing")
}
// Metadata warnings still block destructive cleanup, in the requested language.
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, h)
body := localizedResponse(t, res)
if res.StatusCode != 400 || !strings.Contains(body.Message, i18n.Text(i18n.Locale(language), "metadata_tags")) {
t.Fatal(body)
}
}
got, _, err := f.server.vault.Read("bad.md")
if err != nil || string(got) != content {
t.Fatal("localization changed note contents")
}
if _, err := f.server.vault.Delete("bad.md"); err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, map[string]string{"Accept-Language": language}))
if len(result.Skipped) != 1 || result.Skipped[0].Reason != i18n.Text(i18n.Locale(language), "image_in_use") {
t.Fatal(result)
}
}
}
func TestLocalizedMetadataSkipsDeletedOrMovedWarningNotes(t *testing.T) {
for _, operation := range []string{"delete", "move"} {
t.Run(operation, func(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
f.write("good.md", "Good\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
var err error
if operation == "delete" {
_, err = f.server.vault.Delete("bad.md")
} else {
_, err = f.server.vault.Move("bad.md", "moved.md")
}
if err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
res := f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language})
if res.StatusCode != http.StatusOK {
t.Fatalf("stale warning note invalidated metadata: HTTP %d", res.StatusCode)
}
result := decode[struct{ Notes []search.NoteMetadata }](t, res)
if len(result.Notes) != 1 || result.Notes[0].Path != "good.md" {
t.Fatalf("unexpected surviving metadata: %+v", result.Notes)
}
}
})
}
}
func TestLocalizedMetadataKeepsReanalyzedNoteConsistent(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
const latest = "---\ntitle: Updated title\ntags: [new]\n---\n![](./assets/live.png)\n[Note](./other.md)\n"
f.write("bad.md", latest)
for _, language := range []string{"en", "zh-CN"} {
result := decode[struct{ Notes []search.NoteMetadata }](t, f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language}))
if len(result.Notes) != 1 {
t.Fatal(result)
}
note := result.Notes[0]
if note.Rev != vault.Rev([]byte(latest)) || !reflect.DeepEqual(note.Metadata, vault.Analyze("bad.md", []byte(latest))) {
t.Fatalf("metadata mixes file revisions: %+v", note)
}
}
}
func TestLocalizedMetadataPreservesReadErrors(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
file := filepath.Join(f.root, "bad.md")
if err := os.Remove(file); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(file, 0o755); err != nil {
t.Fatal(err)
}
if res := f.do(f.admin, "GET", "/api/v1/metadata", "", nil); res.StatusCode != http.StatusInternalServerError {
t.Fatalf("read failure was hidden: HTTP %d", res.StatusCode)
}
}
+43 -31
View File
@@ -5,12 +5,12 @@ import (
"context"
"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
+2 -2
View File
@@ -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)
}
})
+29 -29
View File
@@ -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()}
}
+1 -1
View File
@@ -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": [
{
+24 -24
View File
@@ -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()
+1 -1
View File
@@ -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})
+9 -9
View File
@@ -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"))
}
}
+12 -7
View File
@@ -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:
+3 -3
View File
@@ -38,13 +38,13 @@ func newStaticHandler(fsys fs.FS) http.Handler {
func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
}
}
+2 -2
View File
@@ -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 {
+13 -8
View File
@@ -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"`
+4 -3
View File
@@ -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})
+6 -3
View File
@@ -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))
+8 -8
View File
@@ -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])
+30 -30
View File
@@ -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
}
+2 -1
View File
@@ -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 {
+15 -15
View File
@@ -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")
}
+121
View File
@@ -0,0 +1,121 @@
// Package i18n keeps public messages structured until the HTTP boundary.
package i18n
import (
_ "embed"
"encoding/json"
"fmt"
"strconv"
"strings"
)
type Locale string
const English Locale = "en"
const Chinese Locale = "zh-CN"
//go:embed messages.json
var source []byte
var messages = func() map[string]map[Locale]string {
var values map[string]map[Locale]string
if err := json.Unmarshal(source, &values); err != nil {
panic(err)
}
return values
}()
// Negotiate supports the two shipped languages, honors quality weights, and
// defaults to English when the client omits or does not support a language.
func Negotiate(header string) Locale {
selected, best := English, float64(-1)
for _, item := range strings.Split(header, ",") {
parts := strings.Split(strings.TrimSpace(item), ";")
tag := strings.ToLower(strings.TrimSpace(parts[0]))
quality := float64(1)
for _, parameter := range parts[1:] {
if value, ok := strings.CutPrefix(strings.TrimSpace(parameter), "q="); ok {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 || parsed > 1 {
quality = 0
} else {
quality = parsed
}
}
}
var candidate Locale
switch {
case tag == "en" || strings.HasPrefix(tag, "en-") || tag == "*":
candidate = English
case tag == "zh" || tag == "zh-cn" || tag == "zh-hans" || tag == "zh-hans-cn":
candidate = Chinese
default:
continue
}
if quality > 0 && quality > best {
selected, best = candidate, quality
}
}
return selected
}
func Text(locale Locale, key string, args ...any) string {
translations, ok := messages[key]
if !ok {
panic("unknown message key: " + key)
}
format, ok := translations[locale]
if !ok {
panic("unsupported message locale: " + string(locale))
}
if len(args) == 0 {
return format
}
values := make([]any, len(args))
for i, arg := range args {
if err, ok := arg.(error); ok {
values[i] = RenderError(err, locale)
} else {
values[i] = arg
}
}
return fmt.Sprintf(strings.ReplaceAll(format, "%w", "%v"), values...)
}
type Error struct {
Key string
Args []any
causes []error
}
func New(key string, args ...any) *Error {
e := &Error{Key: key, Args: args}
// Preserve fmt.Errorf wrapping semantics: %v shows a cause but does not wrap it.
if len(args) > 0 {
formatted := fmt.Errorf(messages[key][Chinese], args...)
switch wrapped := formatted.(type) {
case interface{ Unwrap() []error }:
e.causes = wrapped.Unwrap()
case interface{ Unwrap() error }:
e.causes = []error{wrapped.Unwrap()}
}
}
return e
}
// Error preserves the established Chinese diagnostics used in logs and indexes.
func (e *Error) Error() string { return Text(Chinese, e.Key, e.Args...) }
func (e *Error) Unwrap() []error { return e.causes }
func RenderError(err error, locale Locale) string {
if localized, ok := err.(*Error); ok {
return Text(locale, localized.Key, localized.Args...)
}
if joined, ok := err.(interface{ Unwrap() []error }); ok {
parts := []string{}
for _, cause := range joined.Unwrap() {
parts = append(parts, RenderError(cause, locale))
}
return strings.Join(parts, "\n")
}
return err.Error()
}
+64
View File
@@ -0,0 +1,64 @@
package i18n
import (
"errors"
"fmt"
"reflect"
"regexp"
"strings"
"testing"
)
func TestNegotiate(t *testing.T) {
for _, test := range []struct {
header string
want Locale
}{
{"", English}, {"fr-FR", English}, {"en-US", English}, {"zh-CN", Chinese},
{"zh-Hans,en;q=0.8", Chinese}, {"zh-CN;q=0.5,en;q=0.9", English},
{"zh-CN;q=0,en", English}, {"zh-CN;q=invalid,en", English},
{"en;q=0.5,zh-CN;q=0.5", English},
} {
if got := Negotiate(test.header); got != test.want {
t.Errorf("%q: %q, want %q", test.header, got, test.want)
}
}
}
func TestCatalogParity(t *testing.T) {
arguments := regexp.MustCompile(`%[a-z]`)
for key, locales := range messages {
if locales[English] == "" || locales[Chinese] == "" {
t.Fatalf("missing translation: %s", key)
}
if !reflect.DeepEqual(arguments.FindAllString(locales[English], -1), arguments.FindAllString(locales[Chinese], -1)) {
t.Errorf("argument mismatch: %s", key)
}
}
}
func TestStructuredErrorsKeepIdentityAndLocalizeCauses(t *testing.T) {
sentinel := New("invalid_locale")
err := New("w_destination_is_not_a_f9de1c", sentinel)
if !errors.Is(err, sentinel) {
t.Fatal("wrapped cause lost")
}
var detail *Error
if !errors.As(err, &detail) {
t.Fatal("structured error type lost")
}
if got := RenderError(err, English); got != "Language must be en or zh-CN: destination is not a folder" {
t.Fatal(got)
}
if got := RenderError(err, Chinese); !strings.Contains(got, "语言必须为 en 或 zh-CN") {
t.Fatal(got)
}
original := errors.New("external diagnostic")
if errors.Is(New("operation_failed", original), original) {
t.Fatal("non-wrapping format changed error identity")
}
if got := RenderError(errors.Join(err, original), English); !strings.Contains(got, "external diagnostic") {
t.Fatal(got)
}
if fmt.Sprint(err) != RenderError(err, Chinese) {
t.Fatal("log language changed")
}
}
+970
View File
@@ -0,0 +1,970 @@
{
"api_endpoint_not_found_c49d86": {
"en": "API endpoint not found",
"zh-CN": "接口不存在"
},
"cross_site_request_rejected_5e7a50": {
"en": "Cross-site request rejected",
"zh-CN": "跨站请求被拒绝"
},
"invalid_request_icon_is_required_16149e": {
"en": "Invalid request: icon is required (null resets the default)",
"zh-CN": "请求格式错误:需要 icon(null 恢复默认)"
},
"note_is_too_large_86d890": {
"en": "Note is too large",
"zh-CN": "笔记过大"
},
"upload_file_is_missing_a2be00": {
"en": "Upload file is missing",
"zh-CN": "缺少上传文件"
},
"a_note_and_a_folder_8b62b5": {
"en": "A note and a folder cannot both be specified",
"zh-CN": "不能同时指定笔记和文件夹"
},
"images_must_not_exceed_mb_61d721": {
"en": "Images must not exceed 50 MB",
"zh-CN": "图片不能超过 50 MB"
},
"could_not_read_uploaded_file_4f04cc": {
"en": "Could not read uploaded file",
"zh-CN": "无法读取上传文件"
},
"invalid_request_format_2529b1": {
"en": "Invalid request format",
"zh-CN": "请求格式错误"
},
"internal_server_error_036549": {
"en": "Internal server error",
"zh-CN": "服务器内部错误"
},
"select_between_and_images_to_338e4e": {
"en": "Select between 1 and 1,000 images to clean up",
"zh-CN": "请选择 1~1000 张待清理图片"
},
"please_sign_in_48144f": {
"en": "Please sign in",
"zh-CN": "请先登录"
},
"credentials_are_invalid_or_expired_e1f63b": {
"en": "Credentials are invalid or expired",
"zh-CN": "登录凭据无效或已过期"
},
"this_endpoint_requires_a_login_d34b96": {
"en": "This endpoint requires a login session",
"zh-CN": "此接口需要登录会话"
},
"read_only_tokens_cannot_modify_6cf8e2": {
"en": "Read-only tokens cannot modify data",
"zh-CN": "只读令牌不能修改数据"
},
"the_verification_code_is_incorrect_6e789b": {
"en": "The verification code is incorrect or already used. Refresh it and try again",
"zh-CN": "验证码错误或已使用,请刷新后重试"
},
"the_verification_code_has_expired_0b3384": {
"en": "The verification code has expired. Refresh it and try again",
"zh-CN": "验证码已过期,请刷新后重试"
},
"invalid_font_installation_information_77cff8": {
"en": "Invalid font installation information",
"zh-CN": "字体安装信息无效"
},
"could_not_read_font_file_b7b543": {
"en": "Could not read font file. The limit is 30 MB per file",
"zh-CN": "无法读取字体文件,单个文件上限 30 MB"
},
"choose_a_font_file_4c95b9": {
"en": "Choose a font file",
"zh-CN": "请选择字体文件"
},
"font_file_exceeds_mb_cc001c": {
"en": "Font file exceeds 30 MB",
"zh-CN": "字体文件超过 30 MB"
},
"import_a_valid_ttf_otf_b67fd3": {
"en": "Import a valid TTF, OTF, WOFF, or WOFF2 font",
"zh-CN": "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件"
},
"font_directory_is_not_configured_73d32a": {
"en": "Font directory is not configured",
"zh-CN": "字体目录未配置"
},
"font_filename_is_invalid_or_a3fd5e": {
"en": "Font filename is invalid or too long",
"zh-CN": "字体文件名无效或过长"
},
"font_not_found_4383e9": {
"en": "Font not found",
"zh-CN": "字体不存在"
},
"a_font_must_be_a_5b8b51": {
"en": "A font must be a regular file",
"zh-CN": "字体必须为普通文件"
},
"maple_is_the_default_font_a8a571": {
"en": "Maple is the default font and cannot be deleted",
"zh-CN": "Maple 是默认字体,不可删除"
},
"font_disabled_but_file_cleanup_d8b892": {
"en": "Font disabled, but file cleanup failed: %w",
"zh-CN": "字体已停用,但文件清理失败:%w"
},
"image_url_must_be_a_391763": {
"en": "Image URL must be a public HTTP or HTTPS address",
"zh-CN": "图片地址必须是公网 HTTP 或 HTTPS 地址"
},
"only_png_jpeg_gif_and_a413cf": {
"en": "Only PNG, JPEG, GIF, and WebP images can be imported",
"zh-CN": "仅支持导入 PNG、JPEG、GIF 和 WebP 图片"
},
"too_many_redirects_from_the_6738ba": {
"en": "Too many redirects from the image source",
"zh-CN": "图片来源重定向次数过多"
},
"invalid_note_path_be69bd": {
"en": "Invalid note path",
"zh-CN": "所属笔记路径无效"
},
"could_not_download_image_b4a293": {
"en": "Could not download image",
"zh-CN": "下载图片失败"
},
"could_not_read_downloaded_image_4b7fdf": {
"en": "Could not read downloaded image",
"zh-CN": "读取下载图片失败"
},
"the_owning_path_must_be_867b53": {
"en": "The owning path must be a note file",
"zh-CN": "所属路径必须是笔记文件"
},
"total_import_size_must_not_23ac37": {
"en": "Total import size must not exceed 100 MB",
"zh-CN": "导入文件总量不能超过 100 MB"
},
"could_not_read_import_form_69f1c3": {
"en": "Could not read import form",
"zh-CN": "无法读取导入表单"
},
"choose_markdown_files_or_a_bea10e": {
"en": "Choose Markdown files or a ZIP",
"zh-CN": "请选择 Markdown 文件或一个 ZIP"
},
"maintenance_is_in_progress_please_64ef9d": {
"en": "Maintenance is in progress. Please try again later",
"zh-CN": "正在维护,请稍后重试"
},
"another_maintenance_operation_is_in_da1aa4": {
"en": "Another maintenance operation is in progress",
"zh-CN": "其他维护操作正在执行"
},
"invalid_maintenance_request_ef9c16": {
"en": "Invalid maintenance request",
"zh-CN": "维护请求无效"
},
"not_enough_disk_space_a53a70": {
"en": "Not enough disk space",
"zh-CN": "磁盘空间不足"
},
"invalid_backup_type_f5ff70": {
"en": "Invalid backup type",
"zh-CN": "备份类型无效"
},
"export_backup_e9d3dd": {
"en": "Export backup",
"zh-CN": "导出备份"
},
"export_note_data_45a4e1": {
"en": "Export note data",
"zh-CN": "导出笔记数据"
},
"w_a_single_setting_exceeds_c7dccb": {
"en": "%w: a single setting exceeds 1 MiB",
"zh-CN": "%w:单项设置超过 1 MiB"
},
"w_preferences_must_be_an_8d2724": {
"en": "%w: preferences must be an object",
"zh-CN": "%w:偏好设置必须为对象"
},
"w_invalid_shortcut_format_0acab4": {
"en": "%w: invalid shortcut format",
"zh-CN": "%w:快捷键格式无效"
},
"w_invalid_plugin_id_7f3512": {
"en": "%w: invalid plugin ID",
"zh-CN": "%w:插件 ID 无效"
},
"w_invalid_plugin_data_1f5fe3": {
"en": "%w: invalid plugin data",
"zh-CN": "%w:插件数据无效"
},
"w_the_settings_package_contains_e70b59": {
"en": "%w: the settings package contains disallowed settings",
"zh-CN": "%w:设置包包含不允许的设置项"
},
"w_settings_package_content_exceeds_baf8eb": {
"en": "%w: settings package content exceeds 32 MiB",
"zh-CN": "%w:设置包设置内容超过 32 MiB"
},
"w_invalid_settings_package_format_2e7bee": {
"en": "%w: invalid settings package format",
"zh-CN": "%w:设置包格式无效"
},
"confirm_settings_import_first_e89aa3": {
"en": "Confirm settings import first",
"zh-CN": "请先确认导入设置"
},
"import_settings_1a80d4": {
"en": "Import settings",
"zh-CN": "导入设置"
},
"confirm_reset_first_22ae3d": {
"en": "Confirm reset first",
"zh-CN": "请先确认重置"
},
"reset_settings_9a70ef": {
"en": "Reset settings",
"zh-CN": "重置设置"
},
"invalid_index_operation_be898b": {
"en": "Invalid index operation",
"zh-CN": "索引操作无效"
},
"index_maintenance_676706": {
"en": "Index maintenance",
"zh-CN": "索引维护"
},
"log_files_are_not_enabled_dcf4df": {
"en": "Log files are not enabled for this service",
"zh-CN": "当前服务尚未启用日志文件"
},
"export_system_logs_d05855": {
"en": "Export system logs",
"zh-CN": "导出系统日志"
},
"w_invalid_upload_id_23d358": {
"en": "%w: invalid upload ID",
"zh-CN": "%w:上传 ID 无效"
},
"w_upload_does_not_exist_918d8d": {
"en": "%w: upload does not exist or has expired",
"zh-CN": "%w:上传不存在或已过期"
},
"w_upload_does_not_belong_c4d06d": {
"en": "%w: upload does not belong to the current session",
"zh-CN": "%w:上传不属于当前会话"
},
"not_enough_disk_space_w_a39b20": {
"en": "Not enough disk space: %w",
"zh-CN": "磁盘空间不足:%w"
},
"choose_a_valid_backup_with_51ba41": {
"en": "Choose a valid backup with a compressed size no greater than 2 GiB",
"zh-CN": "请选择有效备份,压缩大小上限 2 GiB"
},
"prepare_upload_ed0bd9": {
"en": "Prepare upload",
"zh-CN": "准备上传"
},
"invalid_upload_offset_3bdbf1": {
"en": "Invalid upload offset",
"zh-CN": "上传偏移无效"
},
"upload_backup_83b1a9": {
"en": "Upload backup",
"zh-CN": "上传备份"
},
"w_the_resource_directory_contains_3898a5": {
"en": "%w: the resource directory contains a non-directory entry",
"zh-CN": "%w:资源目录内包含非目录条目"
},
"validate_backup_09df97": {
"en": "Validate backup",
"zh-CN": "校验备份"
},
"cancel_upload_a24836": {
"en": "Cancel upload",
"zh-CN": "取消上传"
},
"confirm_replacing_instance_data_and_52f3ca": {
"en": "Confirm replacing instance data and signing in again first",
"zh-CN": "请先确认替换实例数据并重新登录"
},
"this_service_does_not_support_a9a8ba": {
"en": "This service does not support instance restore",
"zh-CN": "当前服务不支持实例恢复"
},
"restore_full_instance_9b4ee0": {
"en": "Restore full instance",
"zh-CN": "恢复完整实例"
},
"clean_temporary_files_af91ae": {
"en": "Clean temporary files",
"zh-CN": "清理临时文件"
},
"invalid_plugin_manifest_a_valid_121d94": {
"en": "Invalid plugin manifest. A valid id, name, version, and apiVersion: 1 are required",
"zh-CN": "插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1"
},
"plugin_directory_does_not_match_0eda90": {
"en": "Plugin directory does not match the manifest ID",
"zh-CN": "插件目录与 manifest id 不一致"
},
"invalid_plugin_installation_information_3e7873": {
"en": "Invalid plugin installation information",
"zh-CN": "插件安装信息无效"
},
"invalid_plugin_enabled_configuration_26d4bb": {
"en": "Invalid plugin enabled configuration",
"zh-CN": "插件启用配置无效"
},
"plugin_not_found_or_installation_f577c6": {
"en": "Plugin not found or installation is incomplete",
"zh-CN": "插件不存在或安装不完整"
},
"could_not_read_plugin_zip_922fd9": {
"en": "Could not read plugin ZIP",
"zh-CN": "无法读取插件 ZIP"
},
"a_plugin_may_contain_at_41c105": {
"en": "A plugin may contain at most 128 files or directories",
"zh-CN": "插件最多包含 128 个文件或目录"
},
"plugin_contains_invalid_paths_duplicate_6a1e05": {
"en": "Plugin contains invalid paths, duplicate paths, or special files",
"zh-CN": "插件包含非法路径、重复路径或特殊文件"
},
"expanded_plugin_exceeds_mib_e06d7b": {
"en": "Expanded plugin exceeds 20 MiB",
"zh-CN": "插件解压后超过 20 MiB"
},
"could_not_read_plugin_file_016a17": {
"en": "Could not read plugin file: %w",
"zh-CN": "无法读取插件文件:%w"
},
"the_zip_root_must_contain_ee8588": {
"en": "The ZIP root must contain manifest.json and main.js",
"zh-CN": "ZIP 根目录必须包含 manifest.json 和 main.js"
},
"main_js_must_be_a_e84ee8": {
"en": "main.js must be a regular file",
"zh-CN": "main.js 必须为普通文件"
},
"plugin_directory_is_not_configured_5e0928": {
"en": "Plugin directory is not configured",
"zh-CN": "插件目录未配置"
},
"could_not_read_plugin_upload_5fd8bc": {
"en": "Could not read plugin upload. Use a ZIP no larger than 10 MiB",
"zh-CN": "无法读取插件上传,请使用不超过 10 MiB 的 ZIP"
},
"choose_a_plugin_zip_file_7360c5": {
"en": "Choose a plugin ZIP file",
"zh-CN": "请选择插件 ZIP 文件"
},
"plugin_zip_exceeds_mib_046e37": {
"en": "Plugin ZIP exceeds 10 MiB",
"zh-CN": "插件 ZIP 超过 10 MiB"
},
"a_plugin_with_this_id_1551cb": {
"en": "A plugin with this ID is already installed. Uninstall it first",
"zh-CN": "同名插件已安装,请先卸载"
},
"enabled_must_be_specified_12873c": {
"en": "enabled must be specified",
"zh-CN": "必须指定 enabled"
},
"plugin_resource_not_found_8e73f3": {
"en": "Plugin resource not found",
"zh-CN": "插件资源不存在"
},
"data_must_be_provided_0dc1bc": {
"en": "data must be provided",
"zh-CN": "必须提供 data"
},
"invalid_resource_ticket_scope_7abece": {
"en": "Invalid resource ticket scope",
"zh-CN": "资源票据范围无效"
},
"plugin_version_changed_refresh_the_9fab75": {
"en": "Plugin version changed. Refresh the page",
"zh-CN": "插件版本已改变,请刷新"
},
"invalid_resource_ticket_purpose_e266b8": {
"en": "Invalid resource ticket purpose",
"zh-CN": "资源票据用途无效"
},
"resource_ticket_has_expired_309b64": {
"en": "Resource ticket has expired",
"zh-CN": "资源票据已失效"
},
"login_session_has_expired_075903": {
"en": "Login session has expired",
"zh-CN": "登录已失效"
},
"this_ticket_only_permits_reading_f26030": {
"en": "This ticket only permits reading images",
"zh-CN": "票据仅允许读取图片"
},
"font_ticket_scope_does_not_95339a": {
"en": "Font ticket scope does not match",
"zh-CN": "字体票据范围不匹配"
},
"plugin_ticket_scope_does_not_349049": {
"en": "Plugin ticket scope does not match",
"zh-CN": "插件票据范围不匹配"
},
"resource_ticket_purpose_does_not_1f573e": {
"en": "Resource ticket purpose does not match",
"zh-CN": "资源票据用途不匹配"
},
"settings_are_too_large_8a580f": {
"en": "Settings are too large",
"zh-CN": "设置过大"
},
"settings_must_be_a_json_45b36b": {
"en": "Settings must be a JSON object",
"zh-CN": "设置必须是 JSON 对象"
},
"current_password_is_incorrect_e0f0aa": {
"en": "Current password is incorrect",
"zh-CN": "当前密码错误"
},
"unsupported_request_method_bc2f08": {
"en": "Unsupported request method",
"zh-CN": "不支持的请求方法"
},
"page_not_found_e75737": {
"en": "Page not found",
"zh-CN": "页面不存在"
},
"frontend_resources_are_missing_2c9b3e": {
"en": "Frontend resources are missing",
"zh-CN": "前端资源缺失"
},
"application_data_directory_is_not_a1f183": {
"en": "Application data directory is not configured",
"zh-CN": "未配置应用数据目录"
},
"theme_file_must_be_a_1fe9df": {
"en": "Theme file must be a regular file",
"zh-CN": "主题文件必须为普通文件"
},
"theme_file_is_too_large_08667a": {
"en": "Theme file is too large",
"zh-CN": "主题文件过大"
},
"could_not_parse_manifest_json_4bef07": {
"en": "Could not parse manifest.json for theme %s: %w",
"zh-CN": "主题 %s 的 manifest.json 无法解析:%w"
},
"theme_s_has_an_invalid_8ed29c": {
"en": "Theme %s has an invalid ID, name, or version",
"zh-CN": "主题 %s 的 id、name 或 version 无效"
},
"theme_not_found_or_files_3b0d5b": {
"en": "Theme not found or files are incomplete",
"zh-CN": "主题不存在或文件不完整"
},
"name_must_contain_between_and_7c157b": {
"en": "Name must contain between 1 and 100 characters",
"zh-CN": "名称不能为空且不超过 100 个字符"
},
"token_not_found_da59eb": {
"en": "Token not found",
"zh-CN": "令牌不存在"
},
"trash_retention_must_be_a_d6bd8f": {
"en": "Trash retention must be a non-negative safe integer. 0 disables automatic deletion",
"zh-CN": "回收站保留天数必须为非负安全整数,0 表示永不自动删除"
},
"some_trash_items_could_not_0a4ca6": {
"en": "Some trash items could not be cleaned: %v",
"zh-CN": "回收站清理部分失败: %v"
},
"setup_is_already_complete_222ec6": {
"en": "Setup is already complete",
"zh-CN": "已完成初始化"
},
"password_must_contain_at_least_decf04": {
"en": "Password must contain at least %d characters",
"zh-CN": "密码至少 %d 位"
},
"incorrect_password_4585c8": {
"en": "Incorrect password",
"zh-CN": "密码错误"
},
"too_many_attempts_please_try_8bbf6d": {
"en": "Too many attempts. Please try again later",
"zh-CN": "尝试次数过多,请稍后再试"
},
"scope_must_be_read_or_2cb466": {
"en": "Scope must be read or write",
"zh-CN": "权限范围只能是 read 或 write"
},
"unrecognized_password_hash_format_2f5457": {
"en": "Unrecognized password hash format",
"zh-CN": "密码哈希格式无法识别"
},
"could_not_parse_password_hash_3cd93b": {
"en": "Could not parse password hash parameters: %w",
"zh-CN": "密码哈希参数无法解析: %w"
},
"invalid_backup_contents_ecff07": {
"en": "Invalid backup contents",
"zh-CN": "备份内容无效"
},
"invalid_or_duplicate_backup_path_ce9c5b": {
"en": "Invalid or duplicate backup path: %s",
"zh-CN": "备份路径无效或重复: %s"
},
"backups_do_not_support_symlinks_872adf": {
"en": "Backups do not support symlinks or special files: %s",
"zh-CN": "备份不支持符号链接或特殊文件: %s"
},
"backup_file_count_exceeds_the_77ac37": {
"en": "Backup file count exceeds the limit",
"zh-CN": "备份文件数超过上限"
},
"expanded_backup_exceeds_gib_b38d18": {
"en": "Expanded backup exceeds 8 GiB",
"zh-CN": "备份展开大小超过 8 GiB"
},
"a_file_changed_during_backup_a85244": {
"en": "A file changed during backup: %s",
"zh-CN": "备份期间文件发生变化: %s"
},
"compressed_backup_exceeds_gib_3962d5": {
"en": "Compressed backup exceeds 2 GiB",
"zh-CN": "备份压缩大小超过 2 GiB"
},
"w_backup_file_count_exceeds_5ba1d4": {
"en": "%w: backup file count exceeds the limit",
"zh-CN": "%w:备份文件数超过上限"
},
"w_invalid_or_duplicate_archive_c11ac2": {
"en": "%w: invalid or duplicate archive path",
"zh-CN": "%w:归档路径无效或重复"
},
"w_archives_cannot_contain_symlinks_fd836b": {
"en": "%w: archives cannot contain symlinks or special files",
"zh-CN": "%w:归档不允许符号链接或特殊文件"
},
"w_a_valid_backup_manifest_8905b1": {
"en": "%w: a valid backup manifest is missing",
"zh-CN": "%w:缺少有效备份清单"
},
"w_invalid_backup_version_type_342220": {
"en": "%w: invalid backup version, type, or manifest",
"zh-CN": "%w:备份版本、类型或清单无效"
},
"w_expanded_backup_exceeds_gib_8e4c5b": {
"en": "%w: expanded backup exceeds 8 GiB",
"zh-CN": "%w:备份展开大小超过 8 GiB"
},
"w_backup_manifest_does_not_69c8ba": {
"en": "%w: backup manifest does not match files",
"zh-CN": "%w:备份清单与文件不符"
},
"w_invalid_directory_size_3ec5c4": {
"en": "%w: invalid directory size",
"zh-CN": "%w:目录大小无效"
},
"w_invalid_file_checksum_ec0b61": {
"en": "%w: invalid file checksum",
"zh-CN": "%w:文件校验值无效"
},
"w_file_verification_failed_s_46b024": {
"en": "%w: file verification failed: %s",
"zh-CN": "%w:文件校验失败: %s"
},
"w_instance_backup_is_missing_4e260c": {
"en": "%w: instance backup is missing the database or vault",
"zh-CN": "%w:实例备份缺少数据库或笔记库"
},
"w_settings_backup_is_missing_46056b": {
"en": "%w: settings backup is missing settings",
"zh-CN": "%w:设置备份缺少设置"
},
"a_single_log_entry_exceeds_b1cdf6": {
"en": "A single log entry exceeds the size limit",
"zh-CN": "单条日志超过大小上限"
},
"w_invalid_backup_database_format_d45d60": {
"en": "%w: invalid backup database format",
"zh-CN": "%w:备份数据库格式无效"
},
"w_unsupported_backup_password_format_184d32": {
"en": "%w: unsupported backup password format",
"zh-CN": "%w:备份密码格式不受支持"
},
"w_invalid_backup_password_salt_315eca": {
"en": "%w: invalid backup password salt",
"zh-CN": "%w:备份密码盐无效"
},
"w_invalid_backup_password_verification_17d1c1": {
"en": "%w: invalid backup password verification value",
"zh-CN": "%w:备份密码校验值无效"
},
"special_files_cannot_be_copied_441836": {
"en": "Special files cannot be copied",
"zh-CN": "不允许复制特殊文件"
},
"invalid_restore_record_state_eee063": {
"en": "Invalid restore record state",
"zh-CN": "恢复记录状态无效"
},
"invalid_restore_record_path_611881": {
"en": "Invalid restore record path",
"zh-CN": "恢复记录路径无效"
},
"the_previous_instance_restore_was_a9d480": {
"en": "The previous instance restore was interrupted",
"zh-CN": "上次实例恢复被中断"
},
"instance_rollback_failed_w_80ec77": {
"en": "Instance rollback failed: %w",
"zh-CN": "实例回退失败: %w"
},
"restore_failed_original_instance_restored_c03846": {
"en": "Restore failed. Original instance restored: %w",
"zh-CN": "导入恢复失败,已回退原实例: %w"
},
"the_restored_instance_has_not_5ca7fb": {
"en": "The restored instance has not completed setup",
"zh-CN": "恢复尚未完成初始化"
},
"settings_import_was_interrupted_original_417ef5": {
"en": "Settings import was interrupted. Original settings and resources restored",
"zh-CN": "设置导入被中断,已回退原设置和资源"
},
"could_not_initialize_search_index_9155c3": {
"en": "Could not initialize search index: %w",
"zh-CN": "初始化搜索索引失败: %w"
},
"invalid_format_for_setting_s_745c8c": {
"en": "Invalid format for setting %s",
"zh-CN": "设置 %s 格式无效"
},
"settings_contain_an_invalid_entry_90c647": {
"en": "Settings contain an invalid entry",
"zh-CN": "设置包含无效设置项"
},
"backup_database_integrity_check_failed_5adb6a": {
"en": "Backup database integrity check failed",
"zh-CN": "备份数据库完整性检查失败"
},
"could_not_initialize_database_w_208f9e": {
"en": "Could not initialize database: %w",
"zh-CN": "初始化数据库失败: %w"
},
"w_invalid_metadata_in_s_478c93": {
"en": "%w: invalid metadata in %s prevents checking vault references: %s",
"zh-CN": "%w:%s 的元数据异常,无法确认全库引用:%s"
},
"w_choose_content_to_export_170d5e": {
"en": "%w: choose content to export",
"zh-CN": "%w:请选择导出内容"
},
"w_invalid_attachment_address_s_8b6ace": {
"en": "%w: invalid attachment address %s",
"zh-CN": "%w:附件地址无效 %s"
},
"attachment_s_w_7bc269": {
"en": "Attachment %s: %w",
"zh-CN": "附件 %s:%w"
},
"w_choose_a_file_with_8410e9": {
"en": "%w: choose a file with the same extension as the original image",
"zh-CN": "%w:请选择与原图片扩展名相同的文件"
},
"w_image_cannot_be_empty_a9a272": {
"en": "%w: image cannot be empty",
"zh-CN": "%w:图片不能为空"
},
"invalid_import_contents_ed556a": {
"en": "Invalid import contents",
"zh-CN": "导入内容无效"
},
"w_choose_markdown_files_or_bcaa52": {
"en": "%w: choose Markdown files or a ZIP",
"zh-CN": "%w:请选择 Markdown 文件或一个 ZIP"
},
"w_invalid_import_folder_name_a10885": {
"en": "%w: invalid import folder name",
"zh-CN": "%w:导入文件夹名称无效"
},
"w_plain_markdown_files_do_08b920": {
"en": "%w: plain Markdown files do not use a wrapper folder",
"zh-CN": "%w:普通 Markdown 不使用外层文件夹"
},
"could_not_clean_the_temporary_212214": {
"en": "Could not clean the temporary import directory: %w",
"zh-CN": "清理导入临时目录失败:%w"
},
"w_zip_contains_an_invalid_7bcf46": {
"en": "%w: ZIP contains an invalid path",
"zh-CN": "%w:ZIP 含有非法路径"
},
"w_duplicate_path_408694": {
"en": "%w: duplicate path",
"zh-CN": "%w:存在重复路径"
},
"w_file_and_directory_paths_eb0b28": {
"en": "%w: file and directory paths conflict",
"zh-CN": "%w:文件与目录路径冲突"
},
"w_could_not_read_zip_d4a777": {
"en": "%w: could not read ZIP: %w",
"zh-CN": "%w:无法读取 ZIP:%w"
},
"w_zip_cannot_contain_symlinks_5f596f": {
"en": "%w: ZIP cannot contain symlinks or special files",
"zh-CN": "%w:ZIP 不允许符号链接或特殊文件"
},
"w_could_not_read_zip_8158b9": {
"en": "%w: could not read ZIP entry: %w",
"zh-CN": "%w:无法读取 ZIP 条目:%w"
},
"w_zip_content_is_damaged_6323c7": {
"en": "%w: ZIP content is damaged: %w",
"zh-CN": "%w:ZIP 内容损坏:%w"
},
"w_only_md_files_or_6f1a4f": {
"en": "%w: only .md files or a single ZIP are accepted",
"zh-CN": "%w:只接受 .md 文件或单个 ZIP"
},
"w_no_importable_markdown_notes_3ad0b8": {
"en": "%w: no importable Markdown notes found",
"zh-CN": "%w:没有可导入的 Markdown 笔记"
},
"w_notes_cannot_be_inside_744972": {
"en": "%w: notes cannot be inside .assets attachment directories",
"zh-CN": "%w:笔记不能放在 .assets 附件目录内"
},
"w_markdown_must_be_utf_ce0d80": {
"en": "%w: Markdown must be UTF-8 text",
"zh-CN": "%w:Markdown 必须是 UTF-8 文本"
},
"w_invalid_metadata_in_s_cc1d10": {
"en": "%w: invalid metadata in %s prevents checking image references",
"zh-CN": "%w:%s 的元数据异常,无法确认图片引用"
},
"w_destination_is_not_a_f9de1c": {
"en": "%w: destination is not a folder",
"zh-CN": "%w:目标不是文件夹"
},
"w_s_choose_a_different_ac4d15": {
"en": "%w: %s; choose a different name",
"zh-CN": "%w:%s,请修改名称"
},
"import_rollback_failed_w_50748f": {
"en": "Import rollback failed: %w",
"zh-CN": "回滚导入失败:%w"
},
"retention_days_cannot_be_negative_f37c9b": {
"en": "Retention days cannot be negative",
"zh-CN": "保留天数不能为负数"
},
"invalid_icon_59d610": {
"en": "Invalid icon",
"zh-CN": "非法图标"
},
"file_tree_icon_metadata_is_49b5aa": {
"en": "File tree icon metadata is damaged: %w",
"zh-CN": "文件树图标元数据损坏: %w"
},
"file_tree_icon_metadata_must_54f104": {
"en": "File tree icon metadata must be an object",
"zh-CN": "文件树图标元数据必须是对象"
},
"file_tree_icon_metadata_contains_eb66b6": {
"en": "File tree icon metadata contains invalid paths or icons",
"zh-CN": "文件树图标元数据含非法路径或图标"
},
"invalid_path_ec93bf": {
"en": "Invalid path",
"zh-CN": "非法路径"
},
"file_not_found_3703e4": {
"en": "File not found",
"zh-CN": "文件不存在"
},
"destination_already_exists_9511c3": {
"en": "Destination already exists",
"zh-CN": "目标已存在"
},
"file_is_too_large_5bfcab": {
"en": "File is too large",
"zh-CN": "文件过大"
},
"could_not_roll_back_the_d7c4fc": {
"en": "Could not roll back the original image: %w",
"zh-CN": "回滚图片原件失败: %w"
},
"could_not_remove_the_copied_e96efd": {
"en": "Could not remove the copied image: %w",
"zh-CN": "撤回图片副本失败: %w"
},
"could_not_roll_back_the_f89084": {
"en": "Could not roll back the old attachment folder: %w",
"zh-CN": "回滚旧附件目录失败: %w"
},
"could_not_roll_back_note_8554a3": {
"en": "Could not roll back note references: %w",
"zh-CN": "回滚笔记引用失败: %w"
},
"could_not_roll_back_the_36ec22": {
"en": "Could not roll back the note move: %w",
"zh-CN": "回滚笔记移动失败: %w"
},
"could_not_move_image_folder_c39928": {
"en": "Could not move image folder: %w",
"zh-CN": "移动图片目录失败: %w"
},
"could_not_roll_back_trashed_b37d4f": {
"en": "Could not roll back trashed images: %w",
"zh-CN": "回滚回收站图片失败: %w"
},
"could_not_roll_back_trashed_3758a4": {
"en": "Could not roll back trashed note: %w",
"zh-CN": "回滚回收站笔记失败: %w"
},
"could_not_move_image_folder_7c4620": {
"en": "Could not move image folder to trash: %w",
"zh-CN": "移动图片目录到回收站失败: %w"
},
"trash_item_s_has_damaged_ba382a": {
"en": "Trash item %s has damaged metadata: %w",
"zh-CN": "回收站条目 %s 元数据损坏: %w"
},
"trash_item_s_has_invalid_006492": {
"en": "Trash item %s has invalid metadata",
"zh-CN": "回收站条目 %s 元数据无效"
},
"trash_item_s_has_damaged_856237": {
"en": "Trash item %s has damaged icon metadata",
"zh-CN": "回收站条目 %s 图标元数据损坏"
},
"could_not_roll_back_restored_952073": {
"en": "Could not roll back restored images: %w",
"zh-CN": "回滚恢复图片失败: %w"
},
"could_not_roll_back_restored_16b803": {
"en": "Could not roll back restored note: %w",
"zh-CN": "回滚恢复笔记失败: %w"
},
"could_not_restore_image_folder_f6e087": {
"en": "Could not restore image folder: %w",
"zh-CN": "恢复图片目录失败: %w"
},
"w_choose_an_image_file_544069": {
"en": "%w: choose an image file",
"zh-CN": "%w:请选择图片文件"
},
"could_not_read_note_s_708ab5": {
"en": "Could not read note %s: %w",
"zh-CN": "读取笔记 %s 失败: %w"
},
"w_w_f0fd7d": {
"en": "%w: %w",
"zh-CN": "%w:%w"
},
"operation_failed": {
"en": "Operation failed: %s",
"zh-CN": "操作失败:%s"
},
"success": {
"en": "Success",
"zh-CN": "成功"
},
"invalid_locale": {
"en": "Language must be en or zh-CN",
"zh-CN": "语言必须为 en 或 zh-CN"
},
"metadata_unclosed": {
"en": "YAML front matter is not closed",
"zh-CN": "YAML 元数据未闭合"
},
"metadata_invalid": {
"en": "Invalid YAML front matter: %v",
"zh-CN": "YAML 元数据无效:%v"
},
"metadata_tags": {
"en": "YAML tags must be a string or a list of strings",
"zh-CN": "YAML tags 必须是字符串或字符串列表"
},
"metadata_tag_list": {
"en": "YAML tags must be a list of strings",
"zh-CN": "YAML tags 必须是字符串列表"
},
"import_missing_links": {
"en": "%s: %d local link targets are not included in the import. Original links retained",
"zh-CN": "%s:%d 个本地链接的目标不在导入内容中,已保留原链接"
},
"image_not_file": {
"en": "Not an image file",
"zh-CN": "不是图片文件"
},
"image_in_use": {
"en": "Still referenced by notes",
"zh-CN": "仍被笔记引用"
},
"version_conflict": {
"en": "Version conflict",
"zh-CN": "版本冲突"
},
"theme_default": {
"en": "Default",
"zh-CN": "默认"
},
"theme_white": {
"en": "Light",
"zh-CN": "白色"
},
"theme_black": {
"en": "Dark",
"zh-CN": "黑色"
},
"w_validate_the_settings_package_0f4f56": {
"en": "%w: validate the settings package first",
"zh-CN": "%w:请先校验设置包"
},
"w_a_validated_upload_cannot_ce0e63": {
"en": "%w: a validated upload cannot be modified",
"zh-CN": "%w:已校验的上传不可修改"
},
"w_upload_offset_does_not_95e21e": {
"en": "%w: upload offset does not match. Upload again",
"zh-CN": "%w:上传偏移不匹配,请重新上传"
},
"w_upload_size_exceeds_the_0bb329": {
"en": "%w: upload size exceeds the manifest",
"zh-CN": "%w:上传大小超出清单"
},
"w_upload_content_is_empty_7c0bb2": {
"en": "%w: upload content is empty",
"zh-CN": "%w:上传内容为空"
},
"w_upload_is_not_complete_8ef575": {
"en": "%w: upload is not complete",
"zh-CN": "%w:上传尚未完成"
},
"w_backup_type_does_not_8e5e8c": {
"en": "%w: backup type does not match the selected operation",
"zh-CN": "%w:备份类型与所选操作不符"
},
"w_vault_content_conflicts_with_6e3d24": {
"en": "%w: vault content conflicts with the application directory",
"zh-CN": "%w:笔记库内容与应用目录冲突"
},
"w_validate_the_full_instance_6d329d": {
"en": "%w: validate the full instance backup first",
"zh-CN": "%w:请先校验完整服务备份"
},
"image_source_http": {
"en": "Image source returned HTTP %d",
"zh-CN": "图片来源返回 HTTP %d"
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"errors"
"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
}
+4 -5
View File
@@ -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)
+2 -2
View File
@@ -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
}
+11 -10
View File
@@ -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)
+5 -5
View File
@@ -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
+3 -3
View File
@@ -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()
+32 -28
View File
@@ -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
+4 -4
View File
@@ -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
}
+17 -11
View File
@@ -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")
}
}
}
+3 -3
View File
@@ -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
+4 -3
View File
@@ -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)
}
+5 -5
View File
@@ -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
+22 -21
View File
@@ -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)
+2 -2
View File
@@ -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)
}