chore: standardize project comments in English

This commit is contained in:
eezd committed 2026-10-26 22:52:12 +08:00
1 parent a874a39ea5
commit ccb16acbea
167 files changed
+820 -820

No files matched your search

+4 -4
View File
@@ -1,4 +1,4 @@
# 本地交接文件与历史设计文档不入库;公开文档单独放行 # Ignore local handoff and historical design documents; allow public guides explicitly.
CONTEXT.md CONTEXT.md
docs/* docs/*
!docs/guide.md !docs/guide.md
@@ -11,14 +11,14 @@ docs/*
!docs/api.zh-CN.md !docs/api.zh-CN.md
!docs/images/ !docs/images/
# 前端 # Frontend
node_modules/ node_modules/
web/dist/ web/dist/
# 本地开发数据 # Local development data
.dev-data/ .dev-data/
deploy/data/ deploy/data/
# 仓库维护的工具规则(其他本地技能不入库) # Repository-maintained tool rules (other local skills stay untracked)
.agents/skills/* .agents/skills/*
!.agents/skills/v3-*/ !.agents/skills/v3-*/
+2 -2
View File
@@ -2,7 +2,7 @@
## 语言与沟通 ## 语言与沟通
- 回复使用简体中文;注释、文档字符串、日志和错误信息遵循项目既有语言约定,无约定时优先使用简体中文。 - 回复使用简体中文;代码注释、文档字符串及配置/脚本中的注释统一使用英文。日志和错误信息遵循项目既有语言约定,无约定时优先使用简体中文。
- 命名使用清晰英文;Git commit 标题与正文使用英文并遵循项目约定。 - 命名使用清晰英文;Git commit 标题与正文使用英文并遵循项目约定。
- Git commit 不得添加 Claude、Claude Code、AI 助手等作为 Contributor、Co-authored-by 或其他署名;不得添加 AI 生成声明或署名 trailer,除非用户明确要求。 - Git commit 不得添加 Claude、Claude Code、AI 助手等作为 Contributor、Co-authored-by 或其他署名;不得添加 AI 生成声明或署名 trailer,除非用户明确要求。
- 先给结论,按需解释,明确未验证的信息。 - 先给结论,按需解释,明确未验证的信息。
@@ -110,7 +110,7 @@ RawMark 是单用户 Markdown 网页笔记编辑器。文件是真源;Go 标
```bash ```bash
pnpm -C web install --frozen-lockfile pnpm -C web install --frozen-lockfile
scripts/dev.sh # 隔离开发数据默认 .dev-data,API_PORT/WEB_PORT 控制端口 scripts/dev.sh # Isolated data defaults to .dev-data; API_PORT/WEB_PORT select ports.
pnpm -C web lint:check pnpm -C web lint:check
pnpm -C web typecheck pnpm -C web typecheck
pnpm -C web test pnpm -C web test
+3 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ---- 前端 ---- # ---- Frontend ----
FROM node:24-alpine AS web FROM node:24-alpine AS web
WORKDIR /src/web WORKDIR /src/web
RUN corepack enable && corepack prepare [email protected] --activate RUN corepack enable && corepack prepare [email protected] --activate
@@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile
COPY web/ ./ COPY web/ ./
RUN pnpm build RUN pnpm build
# ---- 服务端(前端产物嵌入二进制) ---- # ---- Backend (frontend assets embedded in the binary) ----
FROM golang:1-alpine AS server FROM golang:1-alpine AS server
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -20,7 +20,7 @@ COPY web/embed.go web/font_storage.go ./web/
COPY --from=web /src/web/dist ./web/dist COPY --from=web /src/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data
# ---- 运行 ---- # ---- Runtime ----
FROM gcr.io/distroless/static:nonroot FROM gcr.io/distroless/static:nonroot
COPY --from=server /out/rawmark /rawmark COPY --from=server /out/rawmark /rawmark
COPY --from=server --chown=nonroot:nonroot /out/data /data COPY --from=server --chown=nonroot:nonroot /out/data /data
+7 -7
View File
@@ -1,8 +1,8 @@
// RawMark 服务入口。配置全部来自环境变量: // RawMark service entry point. All configuration comes from environment variables:
// //
// RAWMARK_DATA 数据目录,默认 /data(存放 rawmark.db、可重建的搜索索引 index.db 与默认仓库) // RAWMARK_DATA Data directory; defaults to /data (rawmark.db, rebuildable index.db, and the default vault).
// RAWMARK_VAULT 笔记仓库目录,默认 $RAWMARK_DATA/vault;可指向已有的 Typora 笔记目录 // RAWMARK_VAULT Note vault; defaults to $RAWMARK_DATA/vault and may point to an existing Typora directory.
// RAWMARK_ADDR 监听地址,默认 :8080 // RAWMARK_ADDR Listen address; defaults to :8080.
package main package main
import ( import (
@@ -65,7 +65,7 @@ func run(addr string) error {
if err != nil { if err != nil {
return err return err
} }
// 所有标准库上传、导出临时文件归本进程专属目录,不能清理宿主机 /tmp。 // Keep all standard-library upload/export temporary files in this process's own directory; never clean the host /tmp.
tempDir := filepath.Join(dataDir, "temp") tempDir := filepath.Join(dataDir, "temp")
if err = os.MkdirAll(tempDir, 0700); err != nil { if err = os.MkdirAll(tempDir, 0700); err != nil {
return err return err
@@ -208,7 +208,7 @@ func serve(parent context.Context, addr, dataDir, vaultDir, version string, font
return requested, nil return requested, nil
} }
// permissionHint 为权限错误补充排查提示:Docker 绑定挂载的宿主机目录常常对容器内的非 root 用户不可写 // permissionHint adds guidance for permission errors: Docker bind mounts are often not writable by non-root container users.
func permissionHint(err error) error { func permissionHint(err error) error {
if errors.Is(err, fs.ErrPermission) { if errors.Is(err, fs.ErrPermission) {
return fmt.Errorf("%w(当前 UID %d 对数据目录没有写权限:请用 --user 指定宿主机目录所有者的 UID:GID,或执行 chown 修改目录所有者)", err, os.Getuid()) return fmt.Errorf("%w(当前 UID %d 对数据目录没有写权限:请用 --user 指定宿主机目录所有者的 UID:GID,或执行 chown 修改目录所有者)", err, os.Getuid())
@@ -231,7 +231,7 @@ func cleanupSessions(ctx context.Context, st *store.Store) {
} }
} }
// healthcheck 供 Docker HEALTHCHECK 使用(distroless 镜像中没有 curl) // healthcheck supports Docker HEALTHCHECK without curl in the distroless image.
func healthcheck(addr string) int { func healthcheck(addr string) int {
_, port, err := net.SplitHostPort(addr) _, port, err := net.SplitHostPort(addr)
if err != nil { if err != nil {
+3 -3
View File
@@ -1,4 +1,4 @@
# 票据属于资源凭据,日志保留资源路径但不记录明文票据。 # Tickets are resource credentials; retain resource paths in logs without plaintext tickets.
map $uri $rawmark_log_path { map $uri $rawmark_log_path {
~^/api/v1/resources/[^/]+(?<rawmark_resource_suffix>/.*)$ /api/v1/resources/[redacted]$rawmark_resource_suffix; ~^/api/v1/resources/[^/]+(?<rawmark_resource_suffix>/.*)$ /api/v1/resources/[redacted]$rawmark_resource_suffix;
default $uri; default $uri;
@@ -7,7 +7,7 @@ log_format rawmark '$remote_addr - $remote_user [$time_local] '
'"$request_method $rawmark_log_path $server_protocol" $status $body_bytes_sent ' '"$request_method $rawmark_log_path $server_protocol" $status $body_bytes_sent '
'"$http_user_agent"'; '"$http_user_agent"';
# raw.134333.xyz -> RawMark 容器(127.0.0.1:21000) # raw.134333.xyz -> RawMark container (127.0.0.1:21000)
server { server {
listen 80; listen 80;
@@ -38,7 +38,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 1h; proxy_read_timeout 1h;
# Nginx 错误日志不能脱敏请求行;票据路径只保留脱敏访问日志和应用错误日志。 # Nginx error logs cannot redact request lines; ticket paths retain only redacted access logs and application error logs.
location ^~ /api/v1/resources/ { location ^~ /api/v1/resources/ {
proxy_pass http://127.0.0.1:21000; proxy_pass http://127.0.0.1:21000;
error_log /dev/null; error_log /dev/null;
+21 -21
View File
@@ -1,4 +1,4 @@
// Package api 提供 REST API(/api/v1)、事件推送与前端静态资源。前端只通过这套 API 访问数据。 // Package api serves the REST API (/api/v1), events, and frontend assets. The frontend accesses data only through this API.
package api package api
import ( import (
@@ -37,9 +37,9 @@ type Server struct {
store *store.Store store *store.Store
hub *events.Hub hub *events.Hub
index *search.Index index *search.Index
// 串行化设置的"读取-合并-写入" // Serialize settings read/merge/write operations.
settingsMu sync.Mutex settingsMu sync.Mutex
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。 // ThemesDir points to the data directory's themes folder; an empty value enables only built-in themes.
DataDir string DataDir string
ThemesDir string ThemesDir string
PluginsDir string PluginsDir string
@@ -62,14 +62,14 @@ func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, inde
return &Server{vault: v, auth: a, store: st, hub: hub, index: index, captchas: newCaptchas()} return &Server{vault: v, auth: a, store: st, hub: hub, index: index, captchas: newCaptchas()}
} }
// access 为接口的访问要求 // access defines an endpoint's access requirement.
type access int type access int
const ( const (
public access = iota public access = iota
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口) // Login session or API token (read-only tokens may use GET and read-only exports).
authed authed
// 只允许登录会话:管理 API 令牌,避免令牌给自己扩权 // Login sessions only: prevent API tokens from expanding their own permissions through token management.
sessionOnly sessionOnly
) )
@@ -79,7 +79,7 @@ type route struct {
handler http.HandlerFunc handler http.HandlerFunc
} }
// routes 为全部接口;openapi.json 须与之一致(见 TestOpenAPICoversRoutes) // routes lists all endpoints; openapi.json must match it (see TestOpenAPICoversRoutes).
func (s *Server) routes() []route { func (s *Server) routes() []route {
return []route{ return []route{
{"GET /api/v1/health", public, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }}, {"GET /api/v1/health", public, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }},
@@ -170,7 +170,7 @@ func (s *Server) Handler(static fs.FS) http.Handler {
return localized(securityHeaders(sameOrigin(s.guardAPI(mux)))) return localized(securityHeaders(sameOrigin(s.guardAPI(mux))))
} }
// ---- 中间件 ---- // ---- Middleware ----
func securityHeaders(next http.Handler) http.Handler { func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -185,8 +185,8 @@ func securityHeaders(next http.Handler) http.Handler {
}) })
} }
// sameOrigin 保持写请求的同源边界;Bearer 鉴权不依赖浏览器 Cookie。 // sameOrigin preserves the same-origin boundary for writes; Bearer authentication does not rely on browser Cookies.
// 没有 Origin 头的请求来自非浏览器客户端,放行 // Requests without Origin come from non-browser clients and are allowed.
func sameOrigin(next http.Handler) http.Handler { func sameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
@@ -201,7 +201,7 @@ func sameOrigin(next http.Handler) http.Handler {
}) })
} }
// ---- 鉴权 ---- // ---- Authentication ----
func (s *Server) authState(w http.ResponseWriter, r *http.Request) { func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
setUp, err := s.auth.IsSetUp() setUp, err := s.auth.IsSetUp()
@@ -276,7 +276,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"permissions": []string{"vault:read", "vault:write", "instance:manage"}, "expiresAt": identity(r).ExpiresAt}) writeJSON(w, 200, map[string]any{"permissions": []string{"vault:read", "vault:write", "instance:manage"}, "expiresAt": identity(r).ExpiresAt})
} }
// ---- 文件 ---- // ---- Files ----
func (s *Server) tree(w http.ResponseWriter, r *http.Request) { func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
entries, err := s.vault.Tree() entries, err := s.vault.Tree()
@@ -331,7 +331,7 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
w.Write(data) w.Write(data)
} }
// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖 // writeFile uses the entire request body as note content. If-Match: "rev" requires a matching revision; If-None-Match: * requires a new file; omitting both overwrites unconditionally.
func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) { func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes)) data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
if err != nil { if err != nil {
@@ -371,10 +371,10 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
return return
} }
if rewritten { if rewritten {
// 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。 // Atomic rewriting replaced the file identity, so the watcher can no longer identify this move from its snapshot.
s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"}) s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"})
} else { } else {
// 图标提交在文件移动之后;不能仅依赖文件监听器较早的通知。 // Icons commit after the file move; the watcher's earlier notification alone is insufficient.
s.hub.Broadcast(events.Event{Type: "tree"}) s.hub.Broadcast(events.Event{Type: "tree"})
} }
writeJSON(w, 200, map[string]bool{"ok": true}) writeJSON(w, 200, map[string]bool{"ok": true})
@@ -392,7 +392,7 @@ func (s *Server) mkdir(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]bool{"ok": true}) writeJSON(w, 200, map[string]bool{"ok": true})
} }
// uploadAsset 接收 multipart 表单中的 file 字段,存入指定笔记或目录下的共享 assets 目录。 // uploadAsset accepts the multipart file field and saves it in the shared assets directory for the selected note or folder.
func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
@@ -418,7 +418,7 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"link": link}) writeJSON(w, 200, map[string]string{"link": link})
} }
// replaceImage 仅替换已存在的图片;路径与引用保持不变。 // replaceImage replaces only an existing image, preserving its path and references.
func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) { func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil { if err := r.ParseMultipartForm(1 << 20); err != nil {
@@ -444,7 +444,7 @@ func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]bool{"ok": true}) writeJSON(w, 200, map[string]bool{"ok": true})
} }
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行 // raw serves any non-hidden vault file (mainly images). A sandbox CSP prevents SVG/HTML scripts from running on this origin.
func (s *Server) raw(w http.ResponseWriter, r *http.Request) { func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
s.serveRaw(w, r, r.PathValue("path")) s.serveRaw(w, r, r.PathValue("path"))
} }
@@ -465,7 +465,7 @@ func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) {
http.ServeContent(w, r, info.Name(), info.ModTime(), f) http.ServeContent(w, r, info.Name(), info.ModTime(), f)
} }
// ---- 回收站 ---- // ---- Recycle bin ----
func (s *Server) trashList(w http.ResponseWriter, r *http.Request) { func (s *Server) trashList(w http.ResponseWriter, r *http.Request) {
items, err := s.vault.TrashList() items, err := s.vault.TrashList()
@@ -494,9 +494,9 @@ func (s *Server) trashPurge(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]bool{"ok": true}) writeJSON(w, 200, map[string]bool{"ok": true})
} }
// ---- 响应工具 ---- // ---- Response helpers ----
// clientIP 返回限速用的来源地址(未经反向代理头部,避免伪造) // clientIP returns the source address for rate limiting, ignoring proxy headers to prevent spoofing.
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
+5 -5
View File
@@ -33,7 +33,7 @@ type fixture struct {
root string root string
store *store.Store store *store.Store
hub *events.Hub hub *events.Hub
// 已登录的客户端 // Authenticated client.
admin *http.Client admin *http.Client
} }
@@ -88,7 +88,7 @@ func TestEventsRejectRevokedCredentials(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer conn.CloseNow() defer conn.CloseNow()
// 首条事件确认订阅已经建立,避免把握手和广播的时序当作鉴权结果。 // The first event confirms subscription before testing authorization, avoiding handshake/broadcast timing races.
f.hub.Broadcast(events.Event{Type: "tree"}) f.hub.Broadcast(events.Event{Type: "tree"})
if _, _, err := conn.Read(ctx); err != nil { if _, _, err := conn.Read(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -238,7 +238,7 @@ func decode[T any](t *testing.T, res *http.Response) T {
return envelope.Data return envelope.Data
} }
// 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败 // Documentation must match actual endpoints: missing new routes and obsolete documented routes both fail.
func TestOpenAPICoversRoutes(t *testing.T) { func TestOpenAPICoversRoutes(t *testing.T) {
var spec struct { var spec struct {
Paths map[string]map[string]json.RawMessage Paths map[string]map[string]json.RawMessage
@@ -271,7 +271,7 @@ func TestOpenAPICoversRoutes(t *testing.T) {
func TestAPIResponsesAreNotCached(t *testing.T) { func TestAPIResponsesAreNotCached(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("笔记.md", "# 正文") f.write("笔记.md", "# 正文")
// 登录状态与目录曾在 Firefox 离线时命中 HTTP 缓存,误进入无法加载的工作区。 // Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace.
for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} { for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} {
res := f.do(f.admin, "GET", path, "", nil) res := f.do(f.admin, "GET", path, "", nil)
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" { if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" {
@@ -443,7 +443,7 @@ func TestSetupWithoutCode(t *testing.T) {
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 { if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
t.Fatalf("重复初始化必须拒绝: %d", status) t.Fatalf("重复初始化必须拒绝: %d", status)
} }
// 新建 Manager 模拟重启,不能重新开放密码设置。 // A new Manager simulates a restart and must not reopen password setup.
reopened := auth.New(st) reopened := auth.New(st)
if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) { if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) {
t.Fatalf("重启后不能覆盖密码: %v", err) t.Fatalf("重启后不能覆盖密码: %v", err)
+5 -5
View File
@@ -9,11 +9,11 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
) )
// events 通过 WebSocket 推送仓库变更。websocket.Accept 默认校验 Origin 与 Host 一致 // events pushes vault changes over WebSocket. websocket.Accept checks that Origin matches Host by default.
func (s *Server) events(w http.ResponseWriter, r *http.Request) { func (s *Server) events(w http.ResponseWriter, r *http.Request) {
s.eventWG.Add(1) s.eventWG.Add(1)
defer s.eventWG.Done() defer s.eventWG.Done()
// 先订阅再完成握手,避免连接建立后首条变更落在订阅空窗中。 // Subscribe before completing the handshake so the first change cannot fall into a subscription gap.
msgs, unsubscribe := s.hub.Subscribe() msgs, unsubscribe := s.hub.Subscribe()
defer unsubscribe() defer unsubscribe()
conn, err := websocket.Accept(w, r, nil) conn, err := websocket.Accept(w, r, nil)
@@ -36,9 +36,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
} }
return true return true
} }
// 客户端不发消息;CloseRead 负责处理控制帧并在连接断开时取消 ctx // Clients send no messages; CloseRead handles control frames and cancels ctx on disconnect.
ctx := conn.CloseRead(r.Context()) ctx := conn.CloseRead(r.Context())
// 定期 ping,避免反向代理因空闲断开连接 // Ping periodically to prevent idle disconnects by reverse proxies.
ping := time.NewTicker(30 * time.Second) ping := time.NewTicker(30 * time.Second)
defer ping.Stop() defer ping.Stop()
for { for {
@@ -47,7 +47,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
return return
case msg, ok := <-msgs: case msg, ok := <-msgs:
if !ok { if !ok {
// 客户端消费过慢被移出广播列表,断开后由客户端重连并全量同步 // Disconnect slow subscribers removed from the hub; clients reconnect and perform a full sync.
conn.Close(websocket.StatusTryAgainLater, "too slow") conn.Close(websocket.StatusTryAgainLater, "too slow")
return return
} }
+1 -1
View File
@@ -76,7 +76,7 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
// 与真实进程一致,导出暂存文件位于应用数据目录内。 // As in the real process, export temporary files live inside the application data directory.
t.Setenv("TMPDIR", filepath.Join(data, "temp")) t.Setenv("TMPDIR", filepath.Join(data, "temp"))
res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil) res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil)
defer res.Body.Close() defer res.Body.Close()
+1 -1
View File
@@ -235,7 +235,7 @@ func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
return return
} }
defer root.Close() defer root.Close()
// 先撤下资源;设置写失败时恢复,避免留下指向已删除字体的设置。 // Remove the resource first and restore it if settings fail to save, avoiding references to a deleted font.
removed := ".remove-" + id removed := ".remove-" + id
if err := root.Rename(id, removed); err != nil { if err := root.Rename(id, removed); err != nil {
internalError(w, r, err) internalError(w, r, err)
+1 -1
View File
@@ -79,7 +79,7 @@ func TestReplaceImageAPI(t *testing.T) {
if err != nil || string(data) != "![](assets/a.png)" { if err != nil || string(data) != "![](assets/a.png)" {
t.Fatalf("替换不应改变引用:%q %v", data, err) t.Fatalf("替换不应改变引用:%q %v", data, err)
} }
// 复用现有移动与回收站接口,图片不会被当作 Markdown 正文读取。 // Reuse move and recycle-bin endpoints without reading images as Markdown text.
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"assets/a.png","to":"assets/b.png"}`, nil); code != 200 { if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"assets/a.png","to":"assets/b.png"}`, nil); code != 200 {
t.Fatal(code) t.Fatal(code)
} }
+5 -5
View File
@@ -42,7 +42,7 @@ func publicImageIP(ip netip.Addr) bool {
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
return false return false
} }
// 不接受 IPv6 地址转换前缀,避免其中嵌入的 IPv4 地址访问内网。 // Reject IPv6 translation prefixes so embedded IPv4 addresses cannot reach private networks.
if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) { if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) {
return false return false
} }
@@ -64,7 +64,7 @@ func checkImageURL(u *url.URL) error {
var remoteImageClient = &http.Client{ var remoteImageClient = &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
// 不使用环境代理,确保每个实际连接都经过目标地址校验。 // Ignore environment proxies so every actual connection passes destination-address validation.
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error { ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error {
@@ -105,7 +105,7 @@ var remoteImageClient = &http.Client{
}, },
} }
// readImageDownload 按实际文件签名确定类型,不信任来源的扩展名或 Content-Type。 // readImageDownload determines type from file signatures, not the source extension or Content-Type.
func readImageDownload(body io.Reader) ([]byte, string, error) { func readImageDownload(body io.Reader) ([]byte, string, error) {
data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1)) data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1))
if err != nil { if err != nil {
@@ -174,7 +174,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return return
} }
// url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。 // url.Error includes the full source URL, potentially with temporary signatures; log only its underlying cause.
var urlError *url.Error var urlError *url.Error
if errors.As(err, &urlError) { if errors.As(err, &urlError) {
err = urlError.Err err = urlError.Err
@@ -208,7 +208,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
// 下载期间笔记可能被外部程序移动或删除;不能在旧路径下创建孤立资源。 // External programs may move or delete the note during download; do not create orphaned resources at the old path.
info, err := s.vault.Stat(note) info, err := s.vault.Stat(note)
if err != nil { if err != nil {
vaultError(w, r, err) vaultError(w, r, err)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
) )
func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) { func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
// 总请求上限包含表单开销,文件总量另由 vault 校验。 // The total request limit includes form overhead; vault validates the aggregate file size separately.
r.Body = http.MaxBytesReader(w, r.Body, vault.MaxImportBytes+(1<<20)) r.Body = http.MaxBytesReader(w, r.Body, vault.MaxImportBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil { if err := r.ParseMultipartForm(8 << 20); err != nil {
var limit *http.MaxBytesError var limit *http.MaxBytesError
+1 -1
View File
@@ -95,7 +95,7 @@ func TestImportImageDownloadAndRedirects(t *testing.T) {
transport := original.Transport.(*http.Transport).Clone() transport := original.Transport.(*http.Transport).Clone()
dial := transport.DialContext dial := transport.DialContext
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
// 只把固定的测试来源映射到本地真实 HTTP 服务,其他目标仍走生产地址校验。 // Map only the fixed test origin to a real local HTTP server; other destinations retain production address validation.
if address == "images.example:80" { if address == "images.example:80" {
return (&net.Dialer{}).DialContext(ctx, network, source.Listener.Addr().String()) return (&net.Dialer{}).DialContext(ctx, network, source.Listener.Addr().String())
} }
+1 -1
View File
@@ -1,4 +1,4 @@
package api package api
// CloseEvents 在数据库关闭前断开并等待全部 WebSocket 处理结束。 // CloseEvents disconnects and waits for all WebSocket handlers before databases close.
func (s *Server) CloseEvents() { s.hub.Close(); s.eventWG.Wait() } func (s *Server) CloseEvents() { s.hub.Close(); s.eventWG.Wait() }
+2 -2
View File
@@ -29,7 +29,7 @@ type maintenanceStatus struct {
cause error cause error
} }
// gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。 // gate drains ordinary API operations before maintenance; WebSockets do not hold long-lived read locks.
func (s *Server) guardAPI(next http.Handler) http.Handler { func (s *Server) guardAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/v1/health" { if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/v1/health" {
@@ -324,7 +324,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
return values, nil return values, nil
} }
// importSettings 在确认后合并资源;原资源与数据库值都保留到成功提交为止。 // importSettings merges resources after confirmation, retaining original resources and database values until commit succeeds.
func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) { func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
ID string ID string
+3 -3
View File
@@ -71,7 +71,7 @@ func uploadedPackage(t *testing.T, f *fixture, kind string, data []byte) string
t.Fatalf("创建上传失败: %d", res.StatusCode) t.Fatalf("创建上传失败: %d", res.StatusCode)
} }
id := decode[struct{ ID string }](t, res).ID id := decode[struct{ ID string }](t, res).ID
// 跨多个分块验证偏移,不引入测试开关。 // Verify offsets across multiple chunks without introducing test switches.
offset := 0 offset := 0
for offset < len(data) { for offset < len(data) {
end := min(offset+137, len(data)) end := min(offset+137, len(data))
@@ -192,7 +192,7 @@ func TestFullBackupRestoresFilesDatabaseAndInvalidatesSessions(t *testing.T) {
root := t.TempDir() root := t.TempDir()
vault := filepath.Join(root, "vault") vault := filepath.Join(root, "vault")
os.MkdirAll(vault, 0700) os.MkdirAll(vault, 0700)
// 解包后落入目标实例,目标的环境路径与源实例不同。 // Extract into the target instance, whose environment paths differ from the source.
if err = backup.CopyTree(filepath.Join(stage, "vault"), vault); err != nil { if err = backup.CopyTree(filepath.Join(stage, "vault"), vault); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -273,7 +273,7 @@ func TestSettingsPackageDatabaseFailureRollsBack(t *testing.T) {
f.store.SetSetting(settingsKey, `{"fontSize":24}`) f.store.SetSetting(settingsKey, `{"fontSize":24}`)
data := exportedPackage(t, f, "settings") data := exportedPackage(t, f, "settings")
f.store.SetSetting(settingsKey, `{"fontSize":18}`) f.store.SetSetting(settingsKey, `{"fontSize":18}`)
// 通过实际 SQLite 约束制造提交失败,验证备份和事务边界。 // Use an actual SQLite constraint to force commit failure and verify backup and transaction boundaries.
raw, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db")) raw, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+4 -4
View File
@@ -181,7 +181,7 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
} }
func backupResourceError(err error) error { func backupResourceError(err error) error {
var pathError *os.PathError var pathError *os.PathError
// 缺失文件或文件/目录类型错误来自归档内容;其他磁盘故障仍由服务端处理。 // Missing files or file/directory type mismatches are archive errors; other disk failures remain server errors.
if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) { if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) {
return err return err
} }
@@ -368,7 +368,7 @@ type cleanupResult struct {
func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) { func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
result := cleanupResult{Failed: []string{}} result := cleanupResult{Failed: []string{}}
err := s.maintain("clean_temporary_files_af91ae", func() error { err := s.maintain("clean_temporary_files_af91ae", func() error {
// temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。 // temp belongs to this process; ordinary APIs and maintenance have drained. Restore archives and rollback backups live elsewhere.
temp, err := s.tempDir() temp, err := s.tempDir()
if err != nil { if err != nil {
return err return err
@@ -381,7 +381,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
for _, entry := range entries { for _, entry := range entries {
targets = append(targets, filepath.Join(temp, entry.Name())) targets = append(targets, filepath.Join(temp, entry.Name()))
} }
// 只清理过期未使用上传;当前会话新上传和待恢复内容不会被清除。 // Clean only expired unused uploads; retain new uploads from the current session and pending restore contents.
uploads := filepath.Join(s.DataDir, ".maintenance", "uploads") uploads := filepath.Join(s.DataDir, ".maintenance", "uploads")
entries, err = os.ReadDir(uploads) entries, err = os.ReadDir(uploads)
if err != nil && !errors.Is(err, os.ErrNotExist) { if err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -450,7 +450,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, result) writeJSON(w, 200, result)
} }
// 仅用于服务启动前读取恢复结果,不以空响应伪装已恢复。 // Read restore results only before service startup; never disguise an empty response as a completed restore.
func (s *Server) SetRestoreFailure(err error) { func (s *Server) SetRestoreFailure(err error) {
s.maintenanceStatusMu.Lock() s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock() defer s.maintenanceStatusMu.Unlock()
+2 -2
View File
@@ -38,7 +38,7 @@ func TestOpenAPIResponseContracts(t *testing.T) {
} }
return op return op
} }
// 下载、原文、资源代理与 OpenAPI 本身不使用业务 JSON 外壳。 // Downloads, raw files, resource proxies, and OpenAPI itself do not use the business JSON envelope.
raw := map[string]bool{ raw := map[string]bool{
"get /openapi.json": true, "get /files/{path}": true, "get /raw/{path}": true, "get /openapi.json": true, "get /files/{path}": true, "get /raw/{path}": true,
"get /plugins/{id}/assets/{revision}/{path}": true, "get /fonts/{id}/file": true, "get /plugins/{id}/assets/{revision}/{path}": true, "get /fonts/{id}/file": true,
@@ -133,7 +133,7 @@ func TestOpenAPIResponseContracts(t *testing.T) {
check("get", "/maintenance/status", f.do(f.admin, "GET", "/api/v1/maintenance/status", "", nil)) check("get", "/maintenance/status", f.do(f.admin, "GET", "/api/v1/maintenance/status", "", nil))
check("post", "/maintenance/uploads", f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":10}`, nil)) check("post", "/maintenance/uploads", f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":10}`, nil))
id := stagedPackage(t, f, "full", exportedPackage(t, f, "full")) id := stagedPackage(t, f, "full", exportedPackage(t, f, "full"))
// 仅验证提交契约;合成实例不触发真实进程重启或恢复执行。 // Verify only the submission contract; the synthetic instance must not trigger a real process restart or restore.
f.server.Restart = func() {} f.server.Restart = func() {}
check("post", "/maintenance/restore", f.do(f.admin, "POST", "/api/v1/maintenance/restore", `{"id":"`+id+`","confirmed":true}`, nil)) check("post", "/maintenance/restore", f.do(f.admin, "POST", "/api/v1/maintenance/restore", `{"id":"`+id+`","confirmed":true}`, nil))
} }
+1 -1
View File
@@ -124,7 +124,7 @@ func TestPluginInstallBoundariesAndData(t *testing.T) {
if res := pluginUpload(t, f, f.admin, large); res.StatusCode != 400 { if res := pluginUpload(t, f, f.admin, large); res.StatusCode != 400 {
t.Fatalf("解压超限得到 %d", res.StatusCode) t.Fatalf("解压超限得到 %d", res.StatusCode)
} }
// 令牌即使有写权限,也不能植入或读取插件代码及私有配置。 // Even write-capable API tokens cannot install or read plugin code or private configuration.
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"测试","scope":"write"}`, nil)).Token token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"测试","scope":"write"}`, nil)).Token
for _, target := range []string{"/api/v1/plugins", base + "/data", asset} { for _, target := range []string{"/api/v1/plugins", base + "/data", asset} {
if code := f.status(guest, "GET", target, "", map[string]string{"Authorization": "Bearer " + token}); code != 403 { if code := f.status(guest, "GET", target, "", map[string]string{"Authorization": "Bearer " + token}); code != 403 {
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"rawmark/internal/events" "rawmark/internal/events"
) )
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端合并与保存,并校验由服务端执行的清理期限 // Application settings are one JSON object shared across devices. The frontend defines contents; the server merges, saves, and validates server-enforced retention.
const settingsKey = "app.settings" const settingsKey = "app.settings"
func (s *Server) readSettings() (map[string]json.RawMessage, error) { func (s *Server) readSettings() (map[string]json.RawMessage, error) {
@@ -34,8 +34,8 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, settings) writeJSON(w, 200, settings)
} }
// patchSettings 按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。 // patchSettings merges top-level keys: supplied values replace existing values, and null removes a key.
// 不同设备同时修改不同的设置不会互相覆盖;合并后通知其他设备重新读取 // Concurrent changes to different settings do not overwrite each other; notify other devices to reload after merging.
func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes)) data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes))
if err != nil { if err != nil {
@@ -86,7 +86,7 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, settings) writeJSON(w, 200, settings)
} }
// changePassword 修改登录密码;当前会话保留,其他设备需重新登录 // changePassword changes the login password, retaining the current session and requiring other devices to sign in again.
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
var body struct{ Current, Next string } var body struct{ Current, Next string }
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
+8 -8
View File
@@ -12,19 +12,19 @@ import (
"time" "time"
) )
// 应用页面的 CSP:CodeMirror 通过 <style> 注入样式,需要 'unsafe-inline'; // Application CSP: CodeMirror injects <style> elements and requires 'unsafe-inline';
// 笔记中可引用外部图片,img-src 放开 http/https // notes may reference external images, so img-src allows http/https.
const appCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + const appCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob: https: http:; connect-src 'self'; worker-src 'self' blob:; font-src 'self' data:; " + "img-src 'self' data: blob: https: http:; connect-src 'self'; worker-src 'self' blob:; font-src 'self' data:; " +
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'" "object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
type staticFile struct { type staticFile struct {
data []byte data []byte
gzipped []byte // 压缩后不变小时为 nil gzipped []byte // nil when compression does not reduce size.
} }
// staticHandler 提供前端构建产物:带哈希的 assets/ 长期缓存,其余每次校验; // staticHandler serves frontend builds: hashed assets/ files use long-lived caching; other files revalidate on every request.
// 文本资源首次请求时压缩并缓存在内存(嵌入的文件不会变化) // Text resources are compressed and cached in memory on first request (embedded files never change).
type staticHandler struct { type staticHandler struct {
fsys fs.FS fsys fs.FS
started time.Time started time.Time
@@ -42,14 +42,14 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// 已移除的公开入口不能回退到应用首页。 // Removed public entry points must not fall back to the application home page.
if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" { if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
writeError(w, 404, message(w, "page_not_found_e75737")) writeError(w, 404, message(w, "page_not_found_e75737"))
return return
} }
f, err := h.load(name) f, err := h.load(name)
if err != nil { if err != nil {
// 前端路由:未知路径一律返回入口页面 // Frontend routing: unknown paths return the entry page.
name = "index.html" name = "index.html"
if f, err = h.load(name); err != nil { if f, err = h.load(name); err != nil {
writeError(w, 404, message(w, "frontend_resources_are_missing_2c9b3e")) writeError(w, 404, message(w, "frontend_resources_are_missing_2c9b3e"))
@@ -66,7 +66,7 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
hd.Set("Content-Security-Policy", appCSP) hd.Set("Content-Security-Policy", appCSP)
} }
if path.Ext(name) == ".webmanifest" { if path.Ext(name) == ".webmanifest" {
// Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。 // Go's MIME table may lack this extension; do not let ServeContent sniff the compressed bytes.
hd.Set("Content-Type", "application/manifest+json") hd.Set("Content-Type", "application/manifest+json")
} else if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { } else if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
hd.Set("Content-Type", ct) hd.Set("Content-Type", ct)
+3 -3
View File
@@ -39,8 +39,8 @@ func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, usage) writeJSON(w, http.StatusOK, usage)
} }
// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。 // Count logical bytes of regular files only, without following internal symlinks; scan external vaults separately.
// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。 // For nested roots, assign each file to the more specific root to avoid double counting.
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) { func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) {
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}} usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
if dataDir == "" { if dataDir == "" {
@@ -80,7 +80,7 @@ func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, e
} }
if walkErr != nil { if walkErr != nil {
if path != root && errors.Is(walkErr, fs.ErrNotExist) { if path != root && errors.Is(walkErr, fs.ErrNotExist) {
return nil // 文件可能在扫描期间被移动或删除。 return nil // Files may move or disappear during the scan.
} }
return walkErr return walkErr
} }
+1 -1
View File
@@ -19,7 +19,7 @@
--rm-highlight-bg: rgba(232, 196, 119, 0.25); --rm-highlight-bg: rgba(232, 196, 119, 0.25);
} }
/* 绿色按钮使用深色文字,保持按钮的文字对比度。 */ /* Use dark text on green buttons to preserve text contrast. */
.rm-btn-primary, .rm-btn-primary,
.rm-btn-primary:hover, .rm-btn-primary:hover,
.rm-segmented button.on { .rm-segmented button.on {
+1 -1
View File
@@ -40,7 +40,7 @@ func TestThemeBoundaries(t *testing.T) {
if !strings.Contains(theme["css"], "#faf6ed") { if !strings.Contains(theme["css"], "#faf6ed") {
t.Fatal("未读取到主题 CSS") t.Fatal("未读取到主题 CSS")
} }
// CSS 符号链接不能读取主题目录之外的应用数据。 // CSS symlinks must not expose application data outside the theme directory.
if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil { if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -19,7 +19,7 @@ func (s *Server) listTokens(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"tokens": tokens}) writeJSON(w, 200, map[string]any{"tokens": tokens})
} }
// createToken 返回的 token 明文只出现这一次,服务器只保存哈希 // createToken returns the plaintext token only once; the server stores only its hash.
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) { func (s *Server) createToken(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, Scope string } var body struct{ Name, Scope string }
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
) )
// JSON/JavaScript 共同精确表示的非负整数范围;不乘天数,避免期限溢出。 // Nonnegative integers exactly representable in both JSON and JavaScript; avoid multiplying by days, which could overflow.
const maxRetentionDays = 1<<53 - 1 const maxRetentionDays = 1<<53 - 1
func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) { func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) {
@@ -40,7 +40,7 @@ func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
func (s *Server) cleanupTrash(now time.Time) error { func (s *Server) cleanupTrash(now time.Time) error {
s.activity.RLock() s.activity.RLock()
defer s.activity.RUnlock() defer s.activity.RUnlock()
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。 // Serialize with settings writes so cleanup cannot start with stale retention after "never delete" is saved.
s.settingsMu.Lock() s.settingsMu.Lock()
defer s.settingsMu.Unlock() defer s.settingsMu.Unlock()
settings, err := s.readSettings() settings, err := s.readSettings()
@@ -64,7 +64,7 @@ func (s *Server) cleanupTrash(now time.Time) error {
return nil return nil
} }
// RunTrashCleanup 启动时执行一次,其后每小时执行;不依赖浏览器在线。 // RunTrashCleanup runs at startup and hourly thereafter, independently of browser connections.
func (s *Server) RunTrashCleanup(ctx context.Context) { func (s *Server) RunTrashCleanup(ctx context.Context) {
ticker := time.NewTicker(time.Hour) ticker := time.NewTicker(time.Hour)
defer ticker.Stop() defer ticker.Stop()
+14 -14
View File
@@ -1,4 +1,4 @@
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及 API 令牌的校验。 // Package auth implements single-user login (Argon2id hashes, session tokens, rate limiting) and API-token validation.
package auth package auth
import ( import (
@@ -25,13 +25,13 @@ const (
SessionTTL = 30 * 24 * time.Hour SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8 MinPasswordLen = 8
// RFC 9106 推荐的第二组参数(内存受限环境) // The second recommended parameter set in RFC 9106 (memory-constrained environments).
argonTime = 3 argonTime = 3
argonMemory = 64 * 1024 argonMemory = 64 * 1024
argonThreads = 4 argonThreads = 4
argonKeyLen = 32 argonKeyLen = 32
// 同一来源在窗口内累计失败 maxFailures 次即封禁 lockout // Block a source for lockout after maxFailures failures within the window.
maxFailures = 30 maxFailures = 30
failureWindow = 15 * time.Minute failureWindow = 15 * time.Minute
lockout = 15 * time.Minute lockout = 15 * time.Minute
@@ -49,7 +49,7 @@ type Manager struct {
store *store.Store store *store.Store
mu sync.Mutex mu sync.Mutex
failures map[string][]time.Time failures map[string][]time.Time
blocked map[string]time.Time // 来源 -> 解封时间 blocked map[string]time.Time // Source -> unblock time.
now func() time.Time now func() time.Time
} }
@@ -63,7 +63,7 @@ func (m *Manager) IsSetUp() (bool, error) {
} }
func (m *Manager) Setup(password string) error { func (m *Manager) Setup(password string) error {
// 已初始化时提前拒绝,避免公开接口重复计算密码哈希;并发首次写入仍由数据库保证原子性。 // Reject initialized instances early to avoid repeated public password hashing; the database still makes concurrent first writes atomic.
setUp, err := m.IsSetUp() setUp, err := m.IsSetUp()
if err != nil { if err != nil {
return err return err
@@ -85,12 +85,12 @@ func (m *Manager) Setup(password string) error {
return nil return nil
} }
// Login 校验密码并创建会话,返回明文令牌(仅存哈希) // Login validates the password and creates a session, returning the plaintext token (only its hash is stored).
func (m *Manager) Login(ip, password string) (string, error) { func (m *Manager) Login(ip, password string) (string, error) {
return m.AttemptLogin(ip, password, nil) return m.AttemptLogin(ip, password, nil)
} }
// AttemptLogin 在同一限流边界校验登录挑战与密码;初始化无需挑战。 // AttemptLogin validates the login challenge and password within one rate-limit boundary; setup needs no challenge.
func (m *Manager) AttemptLogin(ip, password string, challenge func() error) (string, error) { func (m *Manager) AttemptLogin(ip, password string, challenge func() error) (string, error) {
if !m.allow(ip) { if !m.allow(ip) {
return "", ErrRateLimited return "", ErrRateLimited
@@ -138,7 +138,7 @@ func (m *Manager) SessionExpiry(token string) (time.Time, bool, error) {
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) } func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
// ChangePassword 校验当前密码后设置新密码,并注销 keepToken 以外的所有会话 // ChangePassword verifies the current password, sets the new one, and revokes all sessions except keepToken.
func (m *Manager) ChangePassword(ip, keepToken, current, next string) error { func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
if !m.allow(ip) { if !m.allow(ip) {
return ErrRateLimited return ErrRateLimited
@@ -164,9 +164,9 @@ func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
return m.store.DeleteOtherSessions(hashToken(keepToken)) return m.store.DeleteOtherSessions(hashToken(keepToken))
} }
// ---- API 令牌 ---- // ---- API tokens ----
// CreateAPIToken 创建令牌,返回只显示这一次的明文 // CreateAPIToken creates a token and returns its plaintext value for one-time display.
func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) { func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) {
if scope != "read" && scope != "write" { if scope != "read" && scope != "write" {
return "", store.APIToken{}, ErrBadScope return "", store.APIToken{}, ErrBadScope
@@ -179,7 +179,7 @@ func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, er
return plain, t, nil return plain, t, nil
} }
// ValidateAPIToken 返回令牌的权限范围;无效时 ok 为 false // ValidateAPIToken returns a token's scope; ok is false for an invalid token.
func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) { func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) {
if !strings.HasPrefix(plain, apiTokenPrefix) { if !strings.HasPrefix(plain, apiTokenPrefix) {
return "", false, nil return "", false, nil
@@ -187,7 +187,7 @@ func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err err
return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli()) return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli())
} }
// ---- 限速:同一来源在时间窗口内失败过多则封禁一段时间 ---- // ---- Rate limiting: temporarily block sources with too many failures within the window ----
func (m *Manager) allow(ip string) bool { func (m *Manager) allow(ip string) bool {
m.mu.Lock() m.mu.Lock()
@@ -226,7 +226,7 @@ func (m *Manager) clearFailures(ip string) {
m.mu.Unlock() m.mu.Unlock()
} }
// ---- 密码与令牌 ---- // ---- Passwords and tokens ----
func hashPassword(password string) string { func hashPassword(password string) string {
salt := make([]byte, 16) salt := make([]byte, 16)
@@ -272,7 +272,7 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// randomCode 生成形如 ABCD-EFGH 的 API 令牌标识,去掉易混淆字符 // randomCode generates an API-token identifier such as ABCD-EFGH, excluding ambiguous characters.
func randomCode() string { func randomCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8) b := make([]byte, 8)
+2 -2
View File
@@ -25,7 +25,7 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
return m, &clock return m, &clock
} }
// 登录密码累计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝 // Thirty failed password attempts block the source for fifteen minutes, even with the correct password.
func TestRateLimit(t *testing.T) { func TestRateLimit(t *testing.T) {
m, clock := newManager(t) m, clock := newManager(t)
for i := 0; i < maxFailures-1; i++ { for i := 0; i < maxFailures-1; i++ {
@@ -52,7 +52,7 @@ func TestRateLimit(t *testing.T) {
} }
} }
// 窗口外的失败不计入;登录成功清零 // Failures outside the window do not count; successful login resets the counter.
func TestRateLimitWindow(t *testing.T) { func TestRateLimitWindow(t *testing.T) {
m, clock := newManager(t) m, clock := newManager(t)
for i := 0; i < maxFailures-1; i++ { for i := 0; i < maxFailures-1; i++ {
+4 -4
View File
@@ -1,4 +1,4 @@
// Package backup 处理 RawMark 自有的实例与设置归档;普通 Markdown 导出由 vault 负责。 // Package backup handles RawMark instance and settings archives; vault handles ordinary Markdown exports.
package backup package backup
import ( import (
@@ -23,7 +23,7 @@ const MaxArchiveBytes int64 = 2 << 30
const MaxExpandedBytes int64 = 8 << 30 const MaxExpandedBytes int64 = 8 << 30
const MaxEntries = 100000 const MaxEntries = 100000
// ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。 // ErrInvalidArchive denotes a backup-contract violation, excluding disk or filesystem failures.
var ErrInvalidArchive = i18n.New("invalid_backup_contents_ecff07") var ErrInvalidArchive = i18n.New("invalid_backup_contents_ecff07")
type Entry struct { type Entry struct {
@@ -59,7 +59,7 @@ func validEntry(kind, name string) bool {
return kind == "settings" && name == "settings.json" return kind == "settings" && name == "settings.json"
} }
// Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。 // Create does not follow symlinks and explicitly fails if any file cannot be read.
func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) { func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) {
if kind != "full" && kind != "settings" { if kind != "full" && kind != "settings" {
return i18n.New("invalid_backup_type_f5ff70") return i18n.New("invalid_backup_type_f5ff70")
@@ -182,7 +182,7 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
return f.Sync() return f.Sync()
} }
// Extract 在私有暂存目录校验完整清单和全部文件哈希,绝不写入活动数据目录。 // Extract verifies the full manifest and all file hashes in private staging, never writing to active data directories.
func Extract(ctx context.Context, archive, dest string) (Manifest, error) { func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
var manifest Manifest var manifest Manifest
z, err := zip.OpenReader(archive) z, err := zip.OpenReader(archive)
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"sync" "sync"
) )
// LogWriter 保留三份各不超过 10 MiB 的应用日志,权限 0600。 // LogWriter retains three application log files of at most 10 MiB each, with mode 0600.
type LogWriter struct { type LogWriter struct {
mu sync.Mutex mu sync.Mutex
dir string dir string
@@ -66,7 +66,7 @@ func (l *LogWriter) Write(p []byte) (int, error) {
} }
func (l *LogWriter) Close() error { l.mu.Lock(); defer l.mu.Unlock(); return l.file.Close() } func (l *LogWriter) Close() error { l.mu.Lock(); defer l.mu.Unlock(); return l.file.Close() }
// Snapshot 读取当前日志的稳定副本,避免导出与日志轮转竞争。 // Snapshot reads stable copies of current logs to avoid races with rotation during export.
func (l *LogWriter) Snapshot(dest string) error { func (l *LogWriter) Snapshot(dest string) error {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
+9 -9
View File
@@ -17,8 +17,8 @@ import (
sqlite3 "modernc.org/sqlite/lib" sqlite3 "modernc.org/sqlite/lib"
) )
// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。 // RestoreRecord must run after HTTP, file watching, indexing, and databases have shut down.
// prepared 尚未改动活动数据,applying 表示上次中断,必须先回退。 // prepared means active data is unchanged; applying indicates an interrupted restore that must first roll back.
type RestoreRecord struct { type RestoreRecord struct {
State string `json:"state"` State string `json:"state"`
Kind string `json:"kind,omitempty"` Kind string `json:"kind,omitempty"`
@@ -70,7 +70,7 @@ func Pending(data string) (bool, error) {
} }
func CheckDatabase(file string) error { func CheckDatabase(file string) error {
// 先检查文件签名,Open 的 schema 初始化不能将错误文件伪装为新实例。 // Check file signatures first so Open's schema initialization cannot disguise an invalid file as a new instance.
f, err := os.Open(file) f, err := os.Open(file)
if err != nil { if err != nil {
return err return err
@@ -122,7 +122,7 @@ func CheckDatabase(file string) error {
return nil return nil
} }
// VaultExcluded 支持外置与嵌套仓库,避免将应用文件重复备份为笔记。 // VaultExcluded supports external and nested vaults, avoiding duplicate application files in note backups.
func VaultExcluded(data, vault, rel string) bool { func VaultExcluded(data, vault, rel string) bool {
for _, part := range strings.Split(filepath.ToSlash(rel), "/") { for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
if strings.HasPrefix(part, ".rawmark-tmp-") { if strings.HasPrefix(part, ".rawmark-tmp-") {
@@ -164,7 +164,7 @@ func copyFile(src, dst string, mode os.FileMode) error {
return errors.Join(err, out.Close()) return errors.Join(err, out.Close())
} }
// CopyTree 不覆盖任何已有目标,也不允许跟随链接。 // CopyTree never overwrites an existing destination or follows symlinks.
func CopyTree(src, dest string) error { func CopyTree(src, dest string) error {
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error { return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -205,7 +205,7 @@ func clearVault(data, root, dir string) error {
if VaultExcluded(data, root, rel) { if VaultExcluded(data, root, rel) {
continue continue
} }
// data 在某个祖先目录内时保留祖先,只清理其余子项。 // When data lies inside an ancestor directory, keep that ancestor and clean only the other children.
if entry.IsDir() && strings.HasPrefix(data, full+string(filepath.Separator)) { if entry.IsDir() && strings.HasPrefix(data, full+string(filepath.Separator)) {
if err = clearVault(data, root, full); err != nil { if err = clearVault(data, root, full); err != nil {
return err return err
@@ -258,7 +258,7 @@ func apply(data, vault, stage string) error {
return errors.Join(err, st.Close()) return errors.Join(err, st.Close())
} }
// Recover 在启动时完成预先确认的恢复;若中断或失败,使用操作前备份回退。 // Recover performs a previously confirmed restore at startup, reverting to the pre-operation backup on interruption or failure.
func Recover(data, vault string) (err error) { func Recover(data, vault string) (err error) {
raw, err := os.ReadFile(recordPath(data)) raw, err := os.ReadFile(recordPath(data))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -274,7 +274,7 @@ func Recover(data, vault string) (err error) {
if record.State != "prepared" && record.State != "applying" && record.State != "verifying" { if record.State != "prepared" && record.State != "applying" && record.State != "verifying" {
return i18n.New("invalid_restore_record_state_eee063") return i18n.New("invalid_restore_record_state_eee063")
} }
// 记录由本机生成;仍限制其读取范围,避免错误记录读取任意文件。 // The record is locally generated, but restrict its read scope to prevent malformed records from reading arbitrary files.
if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) { if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) {
return i18n.New("invalid_restore_record_path_611881") return i18n.New("invalid_restore_record_path_611881")
} }
@@ -315,7 +315,7 @@ func Recover(data, vault string) (err error) {
return nil return nil
} }
// FinishRecovery 在新实例的索引、监听与端口初始化成功后提交恢复。 // FinishRecovery commits the restore after the new instance's index, watcher, and listener initialize successfully.
func FinishRecovery(data string) error { func FinishRecovery(data string) error {
raw, err := os.ReadFile(recordPath(data)) raw, err := os.ReadFile(recordPath(data))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
+9 -9
View File
@@ -1,4 +1,4 @@
// Package events 把仓库变更广播给所有在线客户端(WebSocket)。 // Package events broadcasts vault changes to all connected clients over WebSocket.
package events package events
import ( import (
@@ -7,13 +7,13 @@ import (
"sync" "sync"
) )
// Event 为推送给客户端的消息 // Event is a message pushed to clients.
// //
// tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树 // tree Directory structure changed (create/delete/move); clients reload the tree.
// changed 笔记内容变化,Rev 为新版本 // changed Note contents changed; Rev contains the new revision.
// deleted 笔记被删除 // deleted A note was deleted.
// moved 文件或目录被移动(From -> Path),目录事件包含其子项的路径映射 // moved A file or folder moved (From -> Path); folder events include child path mappings.
// settings 应用设置变化,客户端重新读取 // settings Application settings changed; clients reload them.
type Event struct { type Event struct {
Type string `json:"type"` Type string `json:"type"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
@@ -29,7 +29,7 @@ type Hub struct {
func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} } func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} }
// Subscribe 返回消息通道与取消函数;通道在客户端跟不上时会被关闭,客户端应重连并全量同步 // Subscribe returns a message channel and cancel function. Slow clients are disconnected and must reconnect and fully sync.
func (h *Hub) Subscribe() (<-chan []byte, func()) { func (h *Hub) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 64) ch := make(chan []byte, 64)
h.mu.Lock() h.mu.Lock()
@@ -71,7 +71,7 @@ func (h *Hub) Broadcast(events ...Event) {
} }
} }
// Close 禁止新订阅,并通知现有连接关闭,供实例恢复重新打开数据库使用。 // Close rejects new subscriptions and closes existing connections so instance recovery can reopen databases.
func (h *Hub) Close() { func (h *Hub) Close() {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
+1 -1
View File
@@ -24,7 +24,7 @@ func insert(tx *sql.Tx, p, rev string, content []byte) (int64, error) {
return id, err return id, err
} }
// Rebuild 与后台同步互斥,失败时事务和已知快照均保持原状。 // Rebuild excludes background synchronization; failures leave both the transaction and known snapshot unchanged.
func (x *Index) Rebuild(ctx context.Context) error { func (x *Index) Rebuild(ctx context.Context) error {
x.work.Lock() x.work.Lock()
defer x.work.Unlock() defer x.work.Unlock()
+1 -1
View File
@@ -41,7 +41,7 @@ func TestRebuildSupersedesQueuedOldSnapshot(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("old.md", "old") f.write("old.md", "old")
f.sync() f.sync()
f.idx.Update(map[string]string{}) // 尚未消费的旧扫描不应在手动重建后删除新索引。 f.idx.Update(map[string]string{}) // An unconsumed old scan must not delete the new index after a manual rebuild.
f.write("new.md", "中文新笔记") f.write("new.md", "中文新笔记")
if err := f.idx.Rebuild(context.Background()); err != nil { if err := f.idx.Rebuild(context.Background()); err != nil {
t.Fatal(err) t.Fatal(err)
+16 -16
View File
@@ -1,5 +1,5 @@
// Package search 维护笔记的全文索引。索引存放在独立的数据库文件中,内容完全由仓库文件派生, // Package search maintains a full-text note index in a separate database, derived entirely from vault files.
// 删除后会在下次启动时重建;结构变化时也直接重建而不做迁移。 // A deleted index is rebuilt at startup; structural changes also trigger rebuilding rather than migration.
package search package search
import ( import (
@@ -20,7 +20,7 @@ import (
"rawmark/internal/vault" "rawmark/internal/vault"
) )
// 表结构或派生元数据的解析规则变化时递增,旧索引会被丢弃重建。 // Increment when schema or derived-metadata parsing changes; old indexes are discarded and rebuilt.
const schemaVersion = 3 const schemaVersion = 3
const schema = ` const schema = `
@@ -30,7 +30,7 @@ CREATE TABLE docs (
rev TEXT NOT NULL, rev TEXT NOT NULL,
metadata TEXT NOT NULL metadata TEXT NOT NULL
); );
-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE -- Trigram tokenization needs no word separators and supports Chinese fragments; use LIKE for terms shorter than three characters.
CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram'); CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram');
` `
@@ -42,7 +42,7 @@ const (
type Result struct { type Result struct {
Path string `json:"path"` Path string `json:"path"`
// 第一处匹配所在行(从 1 开始)与该行的摘录;只有文件名匹配时 Line 为 0 // First matching line (1-based) and its excerpt; Line is 0 when only the filename matches.
Line int `json:"line"` Line int `json:"line"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
} }
@@ -60,12 +60,12 @@ type Index struct {
v *vault.Vault v *vault.Vault
mu sync.Mutex mu sync.Mutex
pending map[string]string // 待同步的最新快照(路径 -> rev) pending map[string]string // Latest pending snapshot (path -> rev).
wake chan struct{} wake chan struct{}
pendingVersion uint64 pendingVersion uint64
work sync.Mutex // 串行化同步、重建和数据库维护 work sync.Mutex // Serialize synchronization, rebuilding, and database maintenance.
known map[string]doc // 已索引的笔记,仅由同步协程访问 known map[string]doc // Indexed notes, accessed only by the synchronization goroutine.
} }
func Open(path string, v *vault.Vault) (*Index, error) { func Open(path string, v *vault.Vault) (*Index, error) {
@@ -112,7 +112,7 @@ func (x *Index) init() error {
func (x *Index) Close() error { return x.db.Close() } func (x *Index) Close() error { return x.db.Close() }
// Update 提交仓库的最新快照(笔记路径 -> rev),立即返回;同步在后台进行,多次提交只处理最新一次 // Update submits the latest vault snapshot (note path -> rev) and returns immediately; background synchronization consumes only the latest submission.
func (x *Index) Update(files map[string]string) { func (x *Index) Update(files map[string]string) {
x.mu.Lock() x.mu.Lock()
x.pending = files x.pending = files
@@ -124,7 +124,7 @@ func (x *Index) Update(files map[string]string) {
} }
} }
// Run 执行后台同步,直到 ctx 结束 // Run processes background synchronization until ctx ends.
func (x *Index) Run(ctx context.Context) { func (x *Index) Run(ctx context.Context) {
for { for {
select { select {
@@ -143,7 +143,7 @@ func (x *Index) Run(ctx context.Context) {
} }
} }
// sync 让索引与快照一致:只读取新增或 rev 变化的笔记 // sync reconciles the index with the snapshot, reading only new notes or changed revisions.
func (x *Index) sync(files map[string]string) error { func (x *Index) sync(files map[string]string) error {
x.work.Lock() x.work.Lock()
defer x.work.Unlock() defer x.work.Unlock()
@@ -175,7 +175,7 @@ func (x *Index) syncLocked(files map[string]string) error {
} }
content, rev, err := x.v.Read(p) content, rev, err := x.v.Read(p)
if errors.Is(err, vault.ErrNotFound) { if errors.Is(err, vault.ErrNotFound) {
continue // 快照之后被删除,下一次快照会处理 continue // Deleted after the snapshot; the next snapshot will handle it.
} }
if err != nil { if err != nil {
return err return err
@@ -210,8 +210,8 @@ func remove(tx *sql.Tx, id int64) error {
return err return err
} }
// Search 查找同时包含所有词(空白分隔,不区分大小写)的笔记,词可出现在路径或正文中。 // Search finds notes containing every whitespace-separated term, case-insensitively, in paths or contents.
// 路径(文件名或目录名)命中第一个词的排在前面,其余按相关度排序 // Path matches (file or folder names) for the first term rank first; remaining results sort by relevance.
func (x *Index) Search(query string, limit int) ([]Result, error) { func (x *Index) Search(query string, limit int) ([]Result, error) {
terms := strings.Fields(query) terms := strings.Fields(query)
if len(terms) == 0 { if len(terms) == 0 {
@@ -263,7 +263,7 @@ func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
} }
// snippet 返回正文中第一处匹配所在的行号与摘录(过长的行截取匹配附近的一段) // snippet returns the first matching line number and an excerpt, truncating long lines around the match.
func snippet(body string, terms []string) (int, string) { func snippet(body string, terms []string) (int, string) {
folded := make([][]rune, len(terms)) folded := make([][]rune, len(terms))
for i, t := range terms { for i, t := range terms {
@@ -298,7 +298,7 @@ func snippet(body string, terms []string) (int, string) {
return 0, "" return 0, ""
} }
// fold 逐字符转小写,保持字符数不变,便于在原文中按位置截取 // fold lowercases rune by rune, preserving character count for slicing the original text by position.
func fold(rs []rune) []rune { func fold(rs []rune) []rune {
out := make([]rune, len(rs)) out := make([]rune, len(rs))
for i, r := range rs { for i, r := range rs {
+8 -8
View File
@@ -49,7 +49,7 @@ func (f *fixture) write(p, content string) {
} }
} }
// sync 模拟监听器提交快照 // sync simulates watcher snapshot submissions.
func (f *fixture) sync() { func (f *fixture) sync() {
f.t.Helper() f.t.Helper()
entries, err := f.v.Tree() entries, err := f.v.Tree()
@@ -92,11 +92,11 @@ func TestSearchTerms(t *testing.T) {
f.sync() f.sync()
cases := map[string]string{ cases := map[string]string{
"装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS "装饰器": "a.md,目录/c.md", // Use FTS for at least three characters.
"检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE "检索": "b.md,目录/c.md", // Use LIKE for fewer than three characters.
"codemirror": "a.md", // 不区分大小写 "codemirror": "a.md", // Case-insensitive.
"装饰器 检索": "目录/c.md", // 多个词同时满足 "装饰器 检索": "目录/c.md", // Match every term.
"目录": "目录/c.md", // 命中路径 "目录": "目录/c.md", // Path match.
"不存在的词": "", "不存在的词": "",
"100%": "", "100%": "",
`"引号"`: "", `"引号"`: "",
@@ -169,7 +169,7 @@ func TestSyncTracksChanges(t *testing.T) {
} }
} }
// 重启后沿用已有索引,只处理变化 // Reuse the existing index after restart and process only changes.
f.reopen() f.reopen()
if len(f.idx.known) != 2 { if len(f.idx.known) != 2 {
t.Fatalf("重启后应载入已索引笔记: %v", f.idx.known) t.Fatalf("重启后应载入已索引笔记: %v", f.idx.known)
@@ -210,7 +210,7 @@ func TestRebuildsLinkedImageMetadataFromOldIndex(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("a.md", "[![预览](assets/used.png)](https://example.com)") f.write("a.md", "[![预览](assets/used.png)](https://example.com)")
f.sync() f.sync()
// 模拟旧版索引漏记图片引用;笔记正文和 rev 均不改变。 // Simulate an old index missing image references without changing note contents or revisions.
if _, err := f.idx.db.Exec(`UPDATE docs SET metadata = ?`, `{"title":"a","tags":[],"links":[],"assets":[],"warnings":[]}`); err != nil { if _, err := f.idx.db.Exec(`UPDATE docs SET metadata = ?`, `{"title":"a","tags":[],"links":[],"assets":[],"warnings":[]}`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+4 -4
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
) )
// Snapshot 使用 SQLite 生成一致的独立数据库,不复制活动 WAL 文件。 // Snapshot uses SQLite to create a consistent standalone database rather than copying active WAL files.
func (s *Store) Snapshot(ctx context.Context, dest string) error { func (s *Store) Snapshot(ctx context.Context, dest string) error {
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, dest) _, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, dest)
return err return err
@@ -34,7 +34,7 @@ func (s *Store) Preferences() (map[string]json.RawMessage, error) {
return result, rows.Err() return result, rows.Err()
} }
// ReplacePreferences 一次提交全部偏好,账户与会话不属于设置包。 // ReplacePreferences commits all preferences together; accounts and sessions are excluded from settings archives.
func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error { func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
@@ -60,7 +60,7 @@ func (s *Store) ResetPreferences() error {
} }
func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err } func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err }
// CheckBackup 确认数据库可读取且包含当前版本所需表;不会初始化或修复传入文件。 // CheckBackup verifies readability and required tables without initializing or repairing the supplied database.
func (s *Store) CheckBackup() error { func (s *Store) CheckBackup() error {
var result string var result string
if err := s.db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil { if err := s.db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil {
@@ -81,7 +81,7 @@ func (s *Store) CheckBackup() error {
return nil return nil
} }
// OpenExisting 只打开已有库;导入校验不得创建表或更改日志模式。 // OpenExisting opens only existing databases; import validation must not create tables or change journal modes.
func OpenExisting(path string) (*Store, error) { func OpenExisting(path string) (*Store, error) {
db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String()) db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String())
if err != nil { if err != nil {
+8 -8
View File
@@ -1,4 +1,4 @@
// Package store 保存应用自身的数据(设置、会话、API 令牌)。笔记内容不在这里,见 vault。 // Package store persists application data (settings, sessions, API tokens). Note contents belong to vault.
package store package store
import ( import (
@@ -55,7 +55,7 @@ func Open(path string) (*Store, error) {
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
// Setting 读取设置项;不存在时 ok 为 false // Setting reads a setting; ok is false when it does not exist.
func (s *Store) Setting(key string) (value string, ok bool, err error) { func (s *Store) Setting(key string) (value string, ok bool, err error) {
err = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) err = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -69,7 +69,7 @@ func (s *Store) SetSetting(key, value string) error {
return err return err
} }
// SetSettingIfAbsent 仅在设置项不存在时写入,返回是否写入成功(用于首次初始化,防止并发重复设置) // SetSettingIfAbsent writes only a missing key and reports whether it succeeded, preventing concurrent duplicate initialization.
func (s *Store) SetSettingIfAbsent(key, value string) (bool, error) { func (s *Store) SetSettingIfAbsent(key, value string) (bool, error) {
res, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`, key, value) res, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`, key, value)
if err != nil { if err != nil {
@@ -85,7 +85,7 @@ func (s *Store) CreateSession(tokenHash string, expires time.Time) error {
return err return err
} }
// SessionExpiry 只读取有效会话,不延长固定有效期。 // SessionExpiry reads only valid sessions without extending their fixed expiry.
func (s *Store) SessionExpiry(tokenHash string, now time.Time) (time.Time, bool, error) { func (s *Store) SessionExpiry(tokenHash string, now time.Time) (time.Time, bool, error) {
var expires int64 var expires int64
err := s.db.QueryRow(`SELECT expires_at FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now.Unix()).Scan(&expires) err := s.db.QueryRow(`SELECT expires_at FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now.Unix()).Scan(&expires)
@@ -100,7 +100,7 @@ func (s *Store) DeleteSession(tokenHash string) error {
return err return err
} }
// DeleteOtherSessions 删除除 keepHash 之外的所有会话(修改密码后让其他设备重新登录) // DeleteOtherSessions deletes all sessions except keepHash, requiring other devices to sign in after a password change.
func (s *Store) DeleteOtherSessions(keepHash string) error { func (s *Store) DeleteOtherSessions(keepHash string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash) _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash)
return err return err
@@ -115,9 +115,9 @@ func (s *Store) DeleteExpiredSessions() error {
return err return err
} }
// ---- API 令牌 ---- // ---- API tokens ----
// APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write // APIToken is an automation access token stored only as a hash. Scope is read or write.
type APIToken struct { type APIToken struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -150,7 +150,7 @@ func (s *Store) APITokens() ([]APIToken, error) {
return out, rows.Err() return out, rows.Err()
} }
// UseAPIToken 按哈希查找令牌并记录使用时间;不存在时 ok 为 false // UseAPIToken finds a token by hash and records its use time; ok is false when it does not exist.
func (s *Store) UseAPIToken(hash string, now int64) (scope string, ok bool, err error) { func (s *Store) UseAPIToken(hash string, now int64) (scope string, ok bool, err error) {
err = s.db.QueryRow(`UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ? RETURNING scope`, now, hash).Scan(&scope) err = s.db.QueryRow(`UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ? RETURNING scope`, now, hash).Scan(&scope)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"time" "time"
) )
// ResourceTicket 只保存资源凭据哈希,所属登录会话是授权的最终依据。 // ResourceTicket stores only a resource credential hash; the owning login session remains the final authority.
type ResourceTicket struct { type ResourceTicket struct {
Hash string Hash string
SessionHash string SessionHash string
@@ -25,7 +25,7 @@ func (s *Store) Ticket(hash string, consume bool, now time.Time) (ResourceTicket
var t ResourceTicket var t ResourceTicket
var expires int64 var expires int64
query := `SELECT token_hash,session_hash,kind,resource,revision,expires_at FROM resource_tickets WHERE token_hash = ? AND expires_at > ?` query := `SELECT token_hash,session_hash,kind,resource,revision,expires_at FROM resource_tickets WHERE token_hash = ? AND expires_at > ?`
// 事件握手票据原子消费;资源票据可在父会话有效期内读取。 // Event handshake tickets are consumed atomically; resource tickets remain readable while the parent session is valid.
if consume { if consume {
query = `DELETE FROM resource_tickets WHERE token_hash = ? AND expires_at > ? AND kind = 'events' RETURNING token_hash,session_hash,kind,resource,revision,expires_at` query = `DELETE FROM resource_tickets WHERE token_hash = ? AND expires_at > ? AND kind = 'events' RETURNING token_hash,session_hash,kind,resource,revision,expires_at`
} }
+1 -1
View File
@@ -12,7 +12,7 @@ type CleanupSkip struct {
Cause error `json:"-"` Cause error `json:"-"`
} }
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。 // CleanupImages rescans references under the write lock; the derived index never authorizes deletion.
func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) { func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}} result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
v.mu.Lock() v.mu.Lock()
+4 -4
View File
@@ -14,8 +14,8 @@ import (
"strings" "strings"
) )
// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。 // Export builds an export in a temporary file; the caller must close and remove it after success.
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。 // ZIP entries retain vault-relative paths and unchanged text. Export only source files; do not parse or download external resources.
func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) { func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) {
if len(paths) == 0 { if len(paths) == 0 {
return nil, "", i18n.New("w_choose_content_to_export_170d5e", ErrInvalidPath) return nil, "", i18n.New("w_choose_content_to_export_170d5e", ErrInvalidPath)
@@ -46,7 +46,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
files[path.Join(p, entry.Path)] = entry.Dir files[path.Join(p, entry.Path)] = entry.Dir
} }
} }
// 仅收集已选笔记的附件,不追踪链接到的其他笔记。 // Collect attachments only for selected notes, without following links to other notes.
notes := make([]string, 0) notes := make([]string, 0)
for p, dir := range files { for p, dir := range files {
if !dir && IsNote(p) { if !dir && IsNote(p) {
@@ -165,7 +165,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
return tmp, name, nil return tmp, name, nil
} }
// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。 // Attachments may use ../ to reference other folders, but cannot expose hidden paths or escape scope through symlinks.
func (v *Vault) exportPath(rel string) error { func (v *Vault) exportPath(rel string) error {
full, err := v.abs(rel) full, err := v.abs(rel)
if err != nil { if err != nil {
+3 -3
View File
@@ -17,7 +17,7 @@ type MissingAsset struct {
Path string `json:"path"` Path string `json:"path"`
} }
// exclude 由应用层提供,笔记库不持有数据库和资源目录的配置。 // exclude comes from the application layer; the vault does not own database or resource-directory configuration.
func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (result *os.File, err error) { func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (result *os.File, err error) {
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
@@ -100,7 +100,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
return e return e
} }
for _, target := range Analyze(rel, content).Assets { for _, target := range Analyze(rel, content).Assets {
// Analyze 的目标已解析为仓库相对路径,仅检查本地资源。 // Analyze resolves targets to vault-relative paths; inspect only local resources.
if strings.HasPrefix(target.Target, "/") || strings.Contains(target.Target, "://") { if strings.HasPrefix(target.Target, "/") || strings.Contains(target.Target, "://") {
continue continue
} }
@@ -117,7 +117,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
return nil, err return nil, err
} }
if len(missing) > 0 { if len(missing) > 0 {
// 报告放在内部目录,不与用户的普通 Markdown 文件重名;不修改原链接。 // Keep the report in an internal directory to avoid collisions with user Markdown files; preserve original links.
out, e := z.Create(path.Join(".rawmark-export", "missing-assets.json")) out, e := z.Create(path.Join(".rawmark-export", "missing-assets.json"))
if e != nil { if e != nil {
return nil, e return nil, e
+3 -3
View File
@@ -13,7 +13,7 @@ import (
"strings" "strings"
) )
// ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。 // ReplaceImage replaces an existing image in place, preserving reference paths; it cannot create files or change formats.
func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error { func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error {
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) { if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
return i18n.New("w_choose_a_file_with_8410e9", ErrInvalidPath) return i18n.New("w_choose_a_file_with_8410e9", ErrInvalidPath)
@@ -52,7 +52,7 @@ type linkChange struct {
before, after []byte before, after []byte
} }
// 调用方持有写锁;先计算全部改写,移动成功后再写入,出错时保留回滚依据。 // The caller holds the write lock. Compute all rewrites before moving, write them after success, and retain rollback information on error.
func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool, images map[string]string) ([]linkChange, error) { func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool, images map[string]string) ([]linkChange, error) {
entries, err := v.Tree() entries, err := v.Tree()
if err != nil { if err != nil {
@@ -103,7 +103,7 @@ func movedPath(p, from, to string, directory bool) string {
return p return p
} }
// 只替换链接地址,不重新序列化正文;移动和图片归位共用相同的代码/公式边界。 // Replace only link destinations without reserializing text; moves and image relocation share code/math boundaries.
func rewriteLocalLinks(content []byte, oldNote, newNote string, mapTarget func(string) string) ([]byte, error) { func rewriteLocalLinks(content []byte, oldNote, newNote string, mapTarget func(string) string) ([]byte, error) {
var out []byte var out []byte
last := 0 last := 0
+7 -7
View File
@@ -28,7 +28,7 @@ const (
var ErrInvalidImport = i18n.New("invalid_import_contents_ed556a") var ErrInvalidImport = i18n.New("invalid_import_contents_ed556a")
// ImportFile 的读取范围是单个上传文件,ZIP 与普通 Markdown 共用这一输入边界。 // ImportFile bounds reads to one uploaded file; ZIP and plain Markdown share this input boundary.
type ImportFile struct { type ImportFile struct {
Name string Name string
Reader io.ReaderAt Reader io.ReaderAt
@@ -43,7 +43,7 @@ type ImportResult struct {
WarningMessages []*i18n.Error `json:"-"` WarningMessages []*i18n.Error `json:"-"`
} }
// Import 在隐藏目录内整理完整内容,提交前统一检查冲突,绝不覆盖已有文件。 // Import prepares complete contents in a hidden directory and checks all conflicts before commit, never overwriting existing files.
func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) { func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) {
result = ImportResult{Paths: []string{}, Warnings: []string{}} result = ImportResult{Paths: []string{}, Warnings: []string{}}
if len(files) == 0 || len(files) > maxImportEntries { if len(files) == 0 || len(files) > maxImportEntries {
@@ -82,7 +82,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
entries := map[string]struct{}{} entries := map[string]struct{}{}
var total int64 var total int64
var notes []string var notes []string
// 每个来源文件只写一次;目录与文件重名也会在暂存阶段报错。 // Write each source file once; file/directory name collisions also fail during staging.
write := func(name string, isDir bool, reader io.Reader) error { write := func(name string, isDir bool, reader io.Reader) error {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
@@ -140,7 +140,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
for _, f := range z.File { for _, f := range z.File {
if f.Name == ".rawmark-export/missing-assets.json" { if f.Name == ".rawmark-export/missing-assets.json" {
continue continue
} // 本程序笔记导出的缺失附件报告,不作为笔记导入。 } // Do not import RawMark's missing-attachment export report as a note.
if !f.Mode().IsRegular() && !f.Mode().IsDir() { if !f.Mode().IsRegular() && !f.Mode().IsDir() {
return result, i18n.New("w_zip_cannot_contain_symlinks_5f596f", ErrInvalidImport) return result, i18n.New("w_zip_cannot_contain_symlinks_5f596f", ErrInvalidImport)
} }
@@ -207,7 +207,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err return result, err
} }
} }
// 所有笔记改链完成后再清理已迁移的原图,避免后处理的笔记读取不到共享来源。 // Clean migrated source images only after rewriting all notes, so later notes can still read shared sources.
if len(migrated) > 0 { if len(migrated) > 0 {
used := map[string]bool{} used := map[string]bool{}
for _, note := range notes { for _, note := range notes {
@@ -232,7 +232,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
} }
} }
result.Notes = len(notes) result.Notes = len(notes)
// 附件数量为最终保留的文件,包含目录共享图片与其他原始附件。 // The attachment count covers final retained files, including folder-shared images and other original attachments.
count := 0 count := 0
err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error { err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -296,7 +296,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err return result, err
} }
if zipped { if zipped {
// 暂存目录与仓库位于同一文件系统,整个 ZIP 一次 rename 发布。 // Staging and the vault share a filesystem; publish the entire ZIP import with one rename.
err = os.Rename(stage, filepath.Join(parent, folder)) err = os.Rename(stage, filepath.Join(parent, folder))
} else { } else {
var published []string var published []string
+7 -7
View File
@@ -23,7 +23,7 @@ type importLink struct {
html bool html bool
} }
// 链接中的空白允许一次换行,但不能跨过段落。 // Whitespace in links may include one newline but cannot cross paragraphs.
func linkSpace(text []byte, pos int) int { func linkSpace(text []byte, pos int) int {
newline := false newline := false
for pos < len(text) { for pos < len(text) {
@@ -42,7 +42,7 @@ func linkSpace(text []byte, pos int) int {
return pos return pos
} }
// linkDestination 返回地址区间和地址结束后的下标;地址本身不含尖括号。 // linkDestination returns the destination range and the following index; angle brackets are excluded from the destination.
func linkDestination(text []byte, pos int) (start, end, next int) { func linkDestination(text []byte, pos int) (start, end, next int) {
start = linkSpace(text, pos) start = linkSpace(text, pos)
if start == len(text) { if start == len(text) {
@@ -105,7 +105,7 @@ func linkTitleEnd(text []byte, pos int) int {
return -1 return -1
} }
// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。 // importLinkSpans serves import, export, and image renaming, returning original byte positions without reserializing text.
func importLinkSpans(content []byte) []importLink { func importLinkSpans(content []byte) []importLink {
var text []byte var text []byte
var source []int var source []int
@@ -123,7 +123,7 @@ func importLinkSpans(content []byte) []importLink {
lastEnd = offset + len(line) lastEnd = offset + len(line)
return line return line
}) })
// 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。 // Inline code can span lines. Mask its markers while preserving bracket structure around link labels.
masked := bytes.Repeat([]byte(" "), len(text)) masked := bytes.Repeat([]byte(" "), len(text))
pos := 0 pos := 0
for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) { for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) {
@@ -171,7 +171,7 @@ func importLinkSpans(content []byte) []importLink {
} }
add(start, end, false) add(start, end, false)
definitionEnd = next definitionEnd = next
// 引用定义及其标题不是行内正文,避免再次扫描标题中的示例标记。 // Reference definitions and their titles are not inline body text; avoid rescanning example markers in titles.
for i := m[0]; i < next; i++ { for i := m[0]; i < next; i++ {
if text[i] != '\n' && text[i] != '\r' { if text[i] != '\n' && text[i] != '\r' {
text[i] = ' ' text[i] = ' '
@@ -214,7 +214,7 @@ func importLinkSpans(content []byte) []importLink {
add(start, end, false) add(start, end, false)
i = close i = close
if open == 0 || text[open-1] != '!' { if open == 0 || text[open-1] != '!' {
brackets = nil // 链接不能嵌套,图片可以位于链接标签内。 brackets = nil // Links cannot nest, but images can appear inside link labels.
} }
} }
} }
@@ -277,7 +277,7 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。 // Count only newly generated copies; existing attachments with the same name must match the hash-derived contents.
dir := path.Join(path.Dir(note), "assets") dir := path.Join(path.Dir(note), "assets")
dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data) dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data)
if err != nil { if err != nil {
+16 -16
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
) )
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。 // RewriteAssetLinks updates links from the old image directory (oldBase.assets/) after renaming a note.
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。 // Handle inline links/images `](...)`, reference definitions `[x]: ...`, and HTML `src="..."`, skipping code and math.
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/") oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/") oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/")
@@ -43,17 +43,17 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
}) })
} }
// transformMarkdownLines 复用链接改写的容器与代码块识别,避免导入时修改代码示例。 // transformMarkdownLines reuses container/code-block detection from link rewriting to preserve code examples during import.
func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform func(line, body []byte, offset int) []byte) []byte { func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform func(line, body []byte, offset int) []byte) []byte {
lines := bytes.SplitAfter(content, []byte("\n")) lines := bytes.SplitAfter(content, []byte("\n"))
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中 var fence []byte // Current code fence string; nil when outside a fenced block.
var containers []linkContainer var containers []linkContainer
paragraph := false paragraph := false
lineOffset := 0 lineOffset := 0
for i, line := range lines { for i, line := range lines {
offset := lineOffset offset := lineOffset
lineOffset += len(line) lineOffset += len(line)
// 分类时展开制表符,替换仍作用于原始字节,不重新序列化 Markdown。 // Expand tabs for classification, but apply replacements to original bytes without reserializing Markdown.
body := bytes.TrimRight(expandLinkTabs(line), "\r\n") body := bytes.TrimRight(expandLinkTabs(line), "\r\n")
width := len(body) width := len(body)
matched := 0 matched := 0
@@ -68,7 +68,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
body = body[1:] body = body[1:]
} }
} else if indent == len(body) { } else if indent == len(body) {
body = nil // 空行不结束列表容器。 body = nil // Blank lines do not close list containers.
} else if indent >= container.indent { } else if indent >= container.indent {
body = body[container.indent:] body = body[container.indent:]
} else { } else {
@@ -80,14 +80,14 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
rest := bytes.TrimLeft(body, " ") rest := bytes.TrimLeft(body, " ")
if marker := listMarker.Find(rest); len(body)-len(rest) <= 3 && marker != nil { if marker := listMarker.Find(rest); len(body)-len(rest) <= 3 && marker != nil {
mark := bytes.TrimRight(marker, " ") mark := bytes.TrimRight(marker, " ")
// 同级列表的新条目结束上一项段落,不受“只能以 1 打断段落”的限制。 // A new sibling list item ends the previous item's paragraph without the "only 1 interrupts a paragraph" restriction.
if mark[len(mark)-1] == containers[matched].marker { if mark[len(mark)-1] == containers[matched].marker {
paragraph = false paragraph = false
} }
} }
containers = containers[:matched] containers = containers[:matched]
fence = nil // 容器结束时,其内部未闭合的代码块也结束。 fence = nil // Closing a container also closes its unclosed code block.
// 段落允许省略引用或列表前缀的续行,不能把其缩进误认成代码。 // Paragraphs allow lazy continuation without quote/list prefixes; do not mistake their indentation for code.
} }
if fence != nil { if fence != nil {
trimmed := bytes.TrimLeft(body, " ") trimmed := bytes.TrimLeft(body, " ")
@@ -96,7 +96,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
} }
continue continue
} }
// 新的引用或列表可以嵌套;列表的后续行按其内容起始列剥离缩进。 // New quotes or lists may nest; strip continuation indentation using the list's content-start column.
for { for {
indent := len(body) - len(bytes.TrimLeft(body, " ")) indent := len(body) - len(bytes.TrimLeft(body, " "))
if indent > 3 || indent == len(body) { if indent > 3 || indent == len(body) {
@@ -110,10 +110,10 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
body = body[1:] body = body[1:]
} }
} else if paragraphBoundary.Match(rest) { } else if paragraphBoundary.Match(rest) {
break // 分隔线优先于列表标记。 break // Thematic breaks take precedence over list markers.
} else if marker := listMarker.Find(rest); marker != nil { } else if marker := listMarker.Find(rest); marker != nil {
mark := bytes.TrimRight(marker, " ") mark := bytes.TrimRight(marker, " ")
// 有序列表只有从 1 开始且非空时才能打断段落。 // Only nonempty ordered lists starting at 1 may interrupt a paragraph.
if paragraph && (len(marker) == len(rest) || (mark[0] >= '0' && mark[0] <= '9' && !bytes.Equal(mark[:len(mark)-1], []byte("1")))) { if paragraph && (len(marker) == len(rest) || (mark[0] >= '0' && mark[0] <= '9' && !bytes.Equal(mark[:len(mark)-1], []byte("1")))) {
break break
} }
@@ -134,7 +134,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
paragraph = false paragraph = false
continue continue
} }
// 缩进代码不能打断已有段落,列表容器自身的缩进不计入这四列。 // Indented code cannot interrupt an existing paragraph; the list container's indentation does not count toward the four columns.
if len(body)-len(trimmed) >= 4 && !paragraph { if len(body)-len(trimmed) >= 4 && !paragraph {
continue continue
} }
@@ -144,7 +144,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
continue continue
} }
paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed) paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed)
// 将剥离容器时的展开列数映射回原始字节,保留制表符和正文位置。 // Map expanded container columns back to original bytes, preserving tabs and text positions.
start, column := 0, 0 start, column := 0, 0
for column < width-len(body) { for column < width-len(body) {
if line[start] == '\t' { if line[start] == '\t' {
@@ -183,7 +183,7 @@ func expandLinkTabs(line []byte) []byte {
return out return out
} }
// fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串 // fenceOpen recognizes a fenced-code opening (up to three leading spaces, at least three backticks or tildes) and returns its fence string.
func fenceOpen(line []byte) []byte { func fenceOpen(line []byte) []byte {
indent := len(line) - len(bytes.TrimLeft(line, " ")) indent := len(line) - len(bytes.TrimLeft(line, " "))
if indent > 3 { if indent > 3 {
@@ -203,7 +203,7 @@ func fenceOpen(line []byte) []byte {
return rest[:n] return rest[:n]
} }
// transformOutsideCode 只在行内代码 `…` 之外的片段上做转换。 // transformOutsideCode transforms only fragments outside inline code `...`.
func transformOutsideCode(line []byte, transform func(text []byte, offset int) []byte) []byte { func transformOutsideCode(line []byte, transform func(text []byte, offset int) []byte) []byte {
var out []byte var out []byte
offset := 0 offset := 0
+4 -4
View File
@@ -32,7 +32,7 @@ type Metadata struct {
var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`) var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`)
// LocalTarget 在链接边界解码 Markdown 和 URL,拒绝远程地址、绝对路径及越界路径。 // LocalTarget decodes Markdown and URLs at the link boundary, rejecting remote URLs, absolute paths, and out-of-bounds paths.
func LocalTarget(note, raw string) (string, *url.URL, bool) { func LocalTarget(note, raw string) (string, *url.URL, bool) {
raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1")) raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1"))
u, err := url.Parse(raw) u, err := url.Parse(raw)
@@ -46,7 +46,7 @@ func LocalTarget(note, raw string) (string, *url.URL, bool) {
return target, u, true return target, u, true
} }
// Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。 // Analyze extracts semantic relationships without reserializing text; source positions remain original line numbers.
func Analyze(note string, content []byte) Metadata { func Analyze(note string, content []byte) Metadata {
m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}} m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
addWarning := func(key string, args ...any) { addWarning := func(key string, args ...any) {
@@ -138,7 +138,7 @@ func Analyze(note string, content []byte) Metadata {
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
case *ast.Link: case *ast.Link:
addRef(node.Destination.Str(source), node.Pos()) addRef(node.Destination.Str(source), node.Pos())
// 链接文字中可以嵌套图片,仍须记录其资源引用。 // Link labels may contain nested images whose resource references must still be recorded.
case *ast.Image: case *ast.Image:
addRef(node.Destination.Str(source), node.Pos()) addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
@@ -156,7 +156,7 @@ func Analyze(note string, content []byte) Metadata {
} }
return ast.WalkContinue, nil return ast.WalkContinue, nil
}) })
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。 // Historically imported notes may reference HTML resources; cleanup must include them.
excluded := nonProseRanges(root) excluded := nonProseRanges(root)
for _, span := range importLinkSpans(content) { for _, span := range importLinkSpans(content) {
if !span.html { if !span.html {
+3 -3
View File
@@ -12,8 +12,8 @@ import (
"github.com/yuin/goldmark/v2/util" "github.com/yuin/goldmark/v2/util"
) )
// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。 // Relationship extraction only: math and code are not body text, so #1 and example links must not become knowledge relationships.
// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。 // Delimiter rules match frontend Typora extensions; this parser does not render Markdown.
type metadataMath struct{} type metadataMath struct{}
func (metadataMath) Trigger() []byte { return []byte{'$'} } func (metadataMath) Trigger() []byte { return []byte{'$'} }
@@ -79,7 +79,7 @@ func parseMetadata(source []byte) ast.Node {
return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source) return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source)
} }
// 返回非正文的字节范围,供无损链接改写器跳过公式中的示例语法。 // Return non-body byte ranges so lossless link rewriting can skip example syntax inside math.
func nonProseRanges(root ast.Node) [][2]int { func nonProseRanges(root ast.Node) [][2]int {
var ranges [][2]int var ranges [][2]int
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) { _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+3 -3
View File
@@ -16,8 +16,8 @@ type imageTransfer struct {
removed bool removed bool
} }
// 调用方持有写锁,并在后续笔记移动失败时撤回新副本。 // The caller holds the write lock and removes new copies if the subsequent note move fails.
// 旧的独立附件仍由 Move 连同目录移动;共享图片只在其他笔记不再引用时移走原件。 // Move still carries legacy dedicated attachments with their directory; remove shared originals only when no other note references them.
func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targets map[string]string, err error) { func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targets map[string]string, err error) {
content, _, err := v.Read(from) content, _, err := v.Read(from)
if err != nil { if err != nil {
@@ -68,7 +68,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ
} }
info, e := os.Lstat(source) info, e := os.Lstat(source)
if os.IsNotExist(e) { if os.IsNotExist(e) {
continue // 已缺失的图片保留链接,由缺失检查报告。 continue // Retain links to already-missing images for the missing-resource report.
} }
if e != nil { if e != nil {
return transfers, targets, e return transfers, targets, e
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"time" "time"
) )
// 调用方持有写锁并已读取验证元数据。先删除内容,失败时保留条目的管理依据。 // The caller holds the write lock and has validated metadata. Delete contents first, retaining management metadata on failure.
func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error { func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
dir := filepath.Join(v.trash, metadata.ID) dir := filepath.Join(v.trash, metadata.ID)
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
@@ -29,7 +29,7 @@ func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
return err return err
} }
if err := os.Remove(dir); err != nil { if err := os.Remove(dir); err != nil {
// 父目录不可写等原因仍可能阻止最后的 rmdir,恢复元数据以便继续清理。 // A non-writable parent or similar error can still block the final rmdir; restore metadata to allow later cleanup.
data, _ := json.Marshal(metadata) data, _ := json.Marshal(metadata)
return errors.Join(err, atomicWrite(metaPath, data)) return errors.Join(err, atomicWrite(metaPath, data))
} }
@@ -47,8 +47,8 @@ type TrashPurgeResult struct {
Failed []TrashFailure `json:"failed"` Failed []TrashFailure `json:"failed"`
} }
// PurgeTrash 在与恢复相同的写锁内读取元数据并清理。days=0 清空所有; // PurgeTrash reads metadata and cleans entries under the same write lock as restore. days=0 empties everything;
// 自动清理的“永不删除”由调用方处理,不调用本方法。 // the caller handles automatic cleanup's "never delete" setting by not invoking this method.
func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) { func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) {
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
@@ -63,7 +63,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
for _, dir := range dirs { for _, dir := range dirs {
id := dir.Name() id := dir.Name()
if !trashIDPattern.MatchString(id) { if !trashIDPattern.MatchString(id) {
continue // 不删除非本程序管理的文件。 continue // Do not delete files not managed by this application.
} }
metadata, err := v.readTrashMetadata(id) metadata, err := v.readTrashMetadata(id)
if err == nil && days > 0 && (metadata.DeletedAt > now.UnixMilli() || (now.UnixMilli()-metadata.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) { if err == nil && days > 0 && (metadata.DeletedAt > now.UnixMilli() || (now.UnixMilli()-metadata.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) {
+1 -1
View File
@@ -143,7 +143,7 @@ func TestTrashRetentionAndInvalidMetadata(t *testing.T) {
t.Fatalf("不应删除未过期/无效条目: %v", err) t.Fatalf("不应删除未过期/无效条目: %v", err)
} }
} }
// 极大天数不应溢出成过期期限。 // Very large day counts must not overflow into an expired retention period.
result, err = v.PurgeTrash(1<<53-1, now) result, err = v.PurgeTrash(1<<53-1, now)
if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 { if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 {
t.Fatalf("极大期限: %+v, %v", result, err) t.Fatalf("极大期限: %+v, %v", result, err)
+3 -3
View File
@@ -12,7 +12,7 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。 // TreeIcon is a file-tree display attribute and does not affect Markdown contents or revisions.
type TreeIcon struct { type TreeIcon struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Value string `json:"value"` Value string `json:"value"`
@@ -105,7 +105,7 @@ func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error {
return v.writeTreeIcons(icons) return v.writeTreeIcons(icons)
} }
// 从所属子树移出属性,供移动和回收站操作在原有写锁内提交。 // Extract attributes from the owning subtree for move/recycle-bin operations to commit under the existing write lock.
func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon { func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon {
taken := map[string]TreeIcon{} taken := map[string]TreeIcon{}
for p, icon := range icons { for p, icon := range icons {
@@ -117,7 +117,7 @@ func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[s
return taken return taken
} }
// 回收站保留属性,但普通回收站 API 不暴露内部元数据。 // The recycle bin retains attributes, but ordinary recycle-bin APIs do not expose internal metadata.
type trashMetadata struct { type trashMetadata struct {
TrashItem TrashItem
Icons map[string]TreeIcon `json:"icons,omitempty"` Icons map[string]TreeIcon `json:"icons,omitempty"`
+1 -1
View File
@@ -90,7 +90,7 @@ func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) {
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
t.Fatalf("恢复默认失败: %v", got) t.Fatalf("恢复默认失败: %v", got)
} }
// 原有无图标的回收站条目也可恢复。 // Legacy recycle-bin entries without icons remain restorable.
item, err = v.Delete("replacement") item, err = v.Delete("replacement")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+37 -37
View File
@@ -1,4 +1,4 @@
// Package vault 管理笔记仓库目录:文件即真源,所有路径以仓库根为基准、使用 "/" 分隔。 // Package vault manages the note directory: files are the source of truth, and all paths are vault-relative with "/" separators.
package vault package vault
import ( import (
@@ -30,7 +30,7 @@ var (
ErrTooLarge = i18n.New("file_is_too_large_5bfcab") ErrTooLarge = i18n.New("file_is_too_large_5bfcab")
) )
// ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空) // ConflictError indicates a failed If-Match check; Rev is the current server revision (empty if the file does not exist).
type ConflictError struct{ Rev string } type ConflictError struct{ Rev string }
func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() } func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() }
@@ -43,9 +43,9 @@ const (
) )
type Vault struct { type Vault struct {
root string // 已解析符号链接的绝对路径 root string // Absolute path with symlinks resolved.
trash string trash string
// 串行化所有写操作,保证"检查版本 + 写入"、移动等复合操作的原子性 // Serialize writes to make compound operations such as revision-check/write and moves atomic.
mu sync.Mutex mu sync.Mutex
} }
@@ -53,7 +53,7 @@ type Entry struct {
Path string `json:"path"` Path string `json:"path"`
Dir bool `json:"dir"` Dir bool `json:"dir"`
Size int64 `json:"size"` Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // 毫秒 ModTime int64 `json:"mtime"` // Milliseconds.
Info fs.FileInfo `json:"-"` Info fs.FileInfo `json:"-"`
Icon *TreeIcon `json:"icon,omitempty"` Icon *TreeIcon `json:"icon,omitempty"`
} }
@@ -62,8 +62,8 @@ type TrashItem struct {
ID string `json:"id"` ID string `json:"id"`
Path string `json:"path"` Path string `json:"path"`
Dir bool `json:"dir"` Dir bool `json:"dir"`
DeletedAt int64 `json:"deletedAt"` // 毫秒 DeletedAt int64 `json:"deletedAt"` // Milliseconds.
// 随笔记一起删除的 .assets 目录名(相对回收站条目目录) // .assets directory deleted with the note, relative to the recycle-bin entry directory.
Assets string `json:"assets,omitempty"` Assets string `json:"assets,omitempty"`
} }
@@ -88,14 +88,14 @@ func Open(root string) (*Vault, error) {
func (v *Vault) Root() string { return v.root } func (v *Vault) Root() string { return v.root }
// Rev 为内容版本号:sha256 前 8 字节的十六进制 // Rev is the content revision: the first eight SHA-256 bytes in hexadecimal.
func Rev(content []byte) string { func Rev(content []byte) string {
sum := sha256.Sum256(content) sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:8]) return hex.EncodeToString(sum[:8])
} }
// CleanPath 校验客户端传入的相对路径:不允许绝对路径、空段、"."/".." 段以及以 "." 开头的隐藏段 // CleanPath validates client-relative paths: reject absolute paths, empty segments, "."/"..", and hidden segments starting with ".".
// (隐藏段包括 .rawmark 元数据目录与 .git 等,一律不经 API 暴露) // Hidden segments include .rawmark metadata and .git; none are exposed through the API.
func CleanPath(p string) (string, error) { func CleanPath(p string) (string, error) {
if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") { if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") {
return "", ErrInvalidPath return "", ErrInvalidPath
@@ -110,7 +110,7 @@ func CleanPath(p string) (string, error) {
func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) } func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) }
// AssetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets // AssetsDir returns the note's Typora-style image directory: a.md -> a.assets.
func AssetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt } func AssetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt }
func (v *Vault) abs(rel string) (string, error) { func (v *Vault) abs(rel string) (string, error) {
@@ -125,7 +125,7 @@ func (v *Vault) abs(rel string) (string, error) {
return p, nil return p, nil
} }
// checkInside 确认路径最深的已存在祖先在解析符号链接后仍位于仓库内,防止经符号链接逃逸 // checkInside resolves the deepest existing ancestor and ensures it stays inside the vault, preventing symlink escapes.
func (v *Vault) checkInside(p string) error { func (v *Vault) checkInside(p string) error {
for cur := p; ; { for cur := p; ; {
real, err := filepath.EvalSymlinks(cur) real, err := filepath.EvalSymlinks(cur)
@@ -146,7 +146,7 @@ func (v *Vault) checkInside(p string) error {
} }
} }
// ---- 读写 ---- // ---- Reading and writing ----
func (v *Vault) Read(rel string) ([]byte, string, error) { func (v *Vault) Read(rel string) ([]byte, string, error) {
if !IsNote(rel) { if !IsNote(rel) {
@@ -166,7 +166,7 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
return data, Rev(data), nil return data, Rev(data), nil
} }
// Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound // Stat returns regular-file or directory information, or ErrNotFound if missing.
func (v *Vault) Stat(rel string) (fs.FileInfo, error) { func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
p, err := v.abs(rel) p, err := v.abs(rel)
if err != nil { if err != nil {
@@ -179,7 +179,7 @@ func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
return info, err return info, err
} }
// Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖 // Precondition maps HTTP conditions: IfMatch requires the current revision; IfNoneMatch requires a new file; omitting both overwrites unconditionally.
type Precondition struct { type Precondition struct {
IfMatch string IfMatch string
IfNoneMatch bool IfNoneMatch bool
@@ -220,7 +220,7 @@ func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, err
return Rev(content), nil return Rev(content), nil
} }
// atomicWrite 先写同目录临时文件并 fsync,再 rename 覆盖,保证不会留下写了一半的文件 // atomicWrite writes and fsyncs a sibling temporary file before renaming it over the destination, preventing partial files.
func atomicWrite(p string, data []byte) error { func atomicWrite(p string, data []byte) error {
dir := filepath.Dir(p) dir := filepath.Dir(p)
f, err := os.CreateTemp(dir, tmpPrefix+"*") f, err := os.CreateTemp(dir, tmpPrefix+"*")
@@ -228,7 +228,7 @@ func atomicWrite(p string, data []byte) error {
return err return err
} }
tmp := f.Name() tmp := f.Name()
defer os.Remove(tmp) // rename 成功后为空操作 defer os.Remove(tmp) // No-op after a successful rename.
if _, err := f.Write(data); err != nil { if _, err := f.Write(data); err != nil {
f.Close() f.Close()
return err return err
@@ -266,7 +266,7 @@ func (v *Vault) Mkdir(rel string) error {
return os.MkdirAll(p, 0o755) return os.MkdirAll(p, 0o755)
} }
// OpenRaw 打开仓库内的普通文件(图片等资源),调用方负责关闭 // OpenRaw opens a regular vault file (such as an image); the caller must close it.
func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) { func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
p, err := v.abs(rel) p, err := v.abs(rel)
if err != nil { if err != nil {
@@ -290,9 +290,9 @@ func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
return f, info, nil return f, info, nil
} }
// ---- 列表 ---- // ---- Listing ----
// IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。 // IsImage recognizes extensions supported by image management; it does not validate file contents.
func IsImage(p string) bool { func IsImage(p string) bool {
switch strings.ToLower(path.Ext(p)) { switch strings.ToLower(path.Ext(p)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff": case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
@@ -302,7 +302,7 @@ func IsImage(p string) bool {
} }
} }
// Tree 列出文件夹、笔记和图片,沿用 TreeAt 的隐藏路径与符号链接边界。 // Tree lists folders, notes, and images within TreeAt's hidden-path and symlink boundaries.
func (v *Vault) Tree() ([]Entry, error) { func (v *Vault) Tree() ([]Entry, error) {
entries, err := v.TreeAt("") entries, err := v.TreeAt("")
if err != nil { if err != nil {
@@ -327,7 +327,7 @@ func (v *Vault) Tree() ([]Entry, error) {
return visible, nil return visible, nil
} }
// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。 // TreeAt recursively lists regular files and subfolders relative to the selected directory. An empty path selects the vault root; hidden entries and symlinks are not exposed.
func (v *Vault) TreeAt(rel string) ([]Entry, error) { func (v *Vault) TreeAt(rel string) ([]Entry, error) {
root := v.root root := v.root
if rel != "" { if rel != "" {
@@ -371,10 +371,10 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
return entries, err return entries, err
} }
// ---- 移动 / 重命名 ---- // ---- Moving / renaming ----
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。 // Move moves or renames a note, image, or folder and reports whether atomic rewriting changed the moved item's file identity.
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。 // The note's .assets directory follows it; renaming also rewrites links to its own image directory.
func (v *Vault) Move(from, to string) (rewritten bool, err error) { func (v *Vault) Move(from, to string) (rewritten bool, err error) {
src, err := v.abs(from) src, err := v.abs(from)
if err != nil { if err != nil {
@@ -457,7 +457,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return false, err return false, err
} }
} }
// 笔记、目录和图片的移动都可能改变跨文件相对链接。 // Moving notes, folders, or images can change relative links between files.
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets) links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets)
if err != nil { if err != nil {
return false, err return false, err
@@ -517,7 +517,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return rewritten, err return rewritten, err
} }
// ---- 回收站 ---- // ---- Recycle bin ----
func newTrashID() string { func newTrashID() string {
var b [4]byte var b [4]byte
@@ -527,7 +527,7 @@ func newTrashID() string {
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`) var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
// Delete 把笔记(连同 .assets)、图片或文件夹移入回收站 // Delete moves a note (with .assets), image, or folder to the recycle bin.
func (v *Vault) Delete(rel string) (TrashItem, error) { func (v *Vault) Delete(rel string) (TrashItem, error) {
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
@@ -591,7 +591,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
rollbackFailed = true rollbackFailed = true
} }
} }
// 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。 // Retain directories and metadata on rollback failure instead of deleting the last recovery information.
if !rollbackFailed { if !rollbackFailed {
if cleanup := os.RemoveAll(dir); cleanup != nil { if cleanup := os.RemoveAll(dir); cleanup != nil {
err = errors.Join(err, cleanup) err = errors.Join(err, cleanup)
@@ -660,7 +660,7 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
for _, d := range dirs { for _, d := range dirs {
item, err := v.readTrashItem(d.Name()) item, err := v.readTrashItem(d.Name())
if errors.Is(err, ErrInvalidPath) || errors.Is(err, ErrNotFound) { if errors.Is(err, ErrInvalidPath) || errors.Is(err, ErrNotFound) {
continue // 非本程序创建的目录或删除未完成的条目 continue // A directory not created by this application, or an incomplete deletion entry.
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -711,7 +711,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
return return
} }
if cleaning { if cleaning {
// 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。 // Cleanup may have deleted metadata; recreate recovery information before undoing this move.
if rollback := os.MkdirAll(dir, 0o755); rollback != nil { if rollback := os.MkdirAll(dir, 0o755); rollback != nil {
err = errors.Join(err, rollback) err = errors.Join(err, rollback)
return return
@@ -756,12 +756,12 @@ func (v *Vault) Purge(id string) error {
return v.purgeTrashItemLocked(metadata) return v.purgeTrashItemLocked(metadata)
} }
// ---- 资源 ---- // ---- Resources ----
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`) var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
// SaveAsset 把上传的文件存入笔记同级的 assets 目录,以内容哈希命名(相同内容只存一份), // SaveAsset stores an upload in the note's sibling assets directory, named by content hash (identical contents are stored once),
// 返回可直接写入 Markdown 的相对链接 // and returns a relative link ready for Markdown.
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) { func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
if !IsNote(note) { if !IsNote(note) {
return "", ErrInvalidPath return "", ErrInvalidPath
@@ -772,7 +772,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
return v.saveAssetAt(path.Join(path.Dir(note), "assets"), origName, r, limit) return v.saveAssetAt(path.Join(path.Dir(note), "assets"), origName, r, limit)
} }
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。 // SaveFolderAsset uploads to the current folder's shared assets directory for image management, using hash deduplication.
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) { func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
if !IsImage(origName) { if !IsImage(origName) {
return "", i18n.New("w_choose_an_image_file_544069", ErrInvalidPath) return "", i18n.New("w_choose_an_image_file_544069", ErrInvalidPath)
@@ -822,7 +822,7 @@ func (v *Vault) saveAssetAt(relativeDir, origName string, r io.Reader, limit int
return "./" + EscapeLinkPath(path.Base(relativeDir)) + "/" + EscapeLinkPath(path.Base(target)), nil return "./" + EscapeLinkPath(path.Base(relativeDir)) + "/" + EscapeLinkPath(path.Base(target)), nil
} }
// 管理界面允许替换图片,文件名不保证内容未变;只复用实际内容相同的普通文件。 // Image management permits replacement, so filenames do not guarantee unchanged contents; reuse only regular files with matching contents.
func (v *Vault) assetDestination(dir, name string, data []byte) (string, bool, error) { func (v *Vault) assetDestination(dir, name string, data []byte) (string, bool, error) {
base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name) base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
for i := 0; ; i++ { for i := 0; ; i++ {
@@ -879,7 +879,7 @@ func sniffExt(data []byte) string {
} }
} }
// EscapeLinkPath 只转义会破坏 Markdown 链接目标的字符,中文等保持原样(与 Typora 写法一致) // EscapeLinkPath escapes only characters that break Markdown link destinations, preserving Unicode such as Chinese to match Typora.
func EscapeLinkPath(s string) string { func EscapeLinkPath(s string) string {
return linkEscaper.Replace(s) return linkEscaper.Replace(s)
} }
+2 -2
View File
@@ -18,7 +18,7 @@ func newVault(t *testing.T) *Vault {
return v return v
} }
// 显式构造旧版独立附件,覆盖升级后的移动与回收站恢复。 // Construct legacy dedicated attachments explicitly to cover moves and recycle-bin restores after upgrading.
func legacyAsset(t *testing.T, v *Vault, note, name, content string) string { func legacyAsset(t *testing.T, v *Vault, note, name, content string) string {
t.Helper() t.Helper()
dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note))) dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note)))
@@ -85,7 +85,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
legacyAsset(t, v, "n.md", "x.png", "image") legacyAsset(t, v, "n.md", "x.png", "image")
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 // The note name fits, but its derived .assets name exceeds the filesystem's per-segment length limit.
destination := strings.Repeat("a", 250) + ".md" destination := strings.Repeat("a", 250) + ".md"
if _, err := v.Move("n.md", destination); err == nil { if _, err := v.Move("n.md", destination); err == nil {
t.Fatal("图片目录移动应失败") t.Fatal("图片目录移动应失败")
+13 -13
View File
@@ -1,5 +1,5 @@
// Package watch 监听仓库目录的变化(包括 RawMark 自身的写入和外部编辑器/同步工具的修改), // Package watch monitors vault changes, including RawMark writes and external editor/sync-tool changes,
// 与上一次快照比较后把差异作为事件广播。所有变更都经由文件系统这一条路径,客户端据 rev 识别自己的写入。 // and broadcasts differences from the previous snapshot. All changes flow through the filesystem; clients recognize their own writes by rev.
package watch package watch
import ( import (
@@ -22,7 +22,7 @@ import (
const ( const (
debounce = 100 * time.Millisecond debounce = 100 * time.Millisecond
// inotify 在 Docker Desktop 的绑定挂载、NFS 等环境下不可靠,定期全量扫描兜底 // inotify is unreliable on Docker Desktop bind mounts, NFS, and similar setups; periodically perform a full scan.
rescanInterval = 5 * time.Minute rescanInterval = 5 * time.Minute
) )
@@ -43,8 +43,8 @@ type Watcher struct {
onScan func(notes map[string]string) onScan func(notes map[string]string)
} }
// Start 开始监听。onScan 在首次扫描及之后每次有变化的扫描后调用,参数为全部笔记的路径 -> rev(用于更新搜索索引) // Start begins watching. onScan receives all note paths -> rev after the first scan and each changed scan, for search indexing.
// 返回退出通知,实例恢复须等文件监听停止后再替换笔记库。 // Return a shutdown notification; instance recovery must wait for watching to stop before replacing the vault.
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) (<-chan struct{}, error) { func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) (<-chan struct{}, error) {
fw, err := fsnotify.NewWatcher() fw, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
@@ -82,7 +82,7 @@ func (w *Watcher) loop(ctx context.Context) {
if !ok { if !ok {
return return
} }
// 事件队列溢出等情况会丢事件,立即全量扫描补齐 // Queue overflows can lose events; immediately rescan to recover them.
slog.Warn("文件监听出错,执行全量扫描", "err", err) slog.Warn("文件监听出错,执行全量扫描", "err", err)
timer.Reset(debounce) timer.Reset(debounce)
case <-timer.C: case <-timer.C:
@@ -126,7 +126,7 @@ func notes(snap map[string]fileState) map[string]string {
return out return out
} }
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算 // scan reads the current tree and recalculates revisions only for new files or changes in size/modification time.
func (w *Watcher) scan() (map[string]fileState, error) { func (w *Watcher) scan() (map[string]fileState, error) {
entries, err := w.v.TreeAt("") entries, err := w.v.TreeAt("")
if err != nil { if err != nil {
@@ -145,7 +145,7 @@ func (w *Watcher) scan() (map[string]fileState, error) {
} else { } else {
data, err := os.ReadFile(abs) data, err := os.ReadFile(abs)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
continue // 扫描期间被删除,下一轮会处理 continue // Deleted during scanning; the next pass will handle it.
} }
if err != nil { if err != nil {
return nil, i18n.New("could_not_read_note_s_708ab5", e.Path, err) return nil, i18n.New("could_not_read_note_s_708ab5", e.Path, err)
@@ -166,13 +166,13 @@ func (w *Watcher) scan() (map[string]fileState, error) {
} }
for d := range w.watched { for d := range w.watched {
if !dirs[d] { if !dirs[d] {
delete(w.watched, d) // 目录已删除,inotify 会自动移除监听 delete(w.watched, d) // The directory was deleted; inotify removes its watch automatically.
} }
} }
return next, nil return next, nil
} }
// diff 按文件系统身份识别移动,内容相同不代表同一个文件。 // diff identifies moves by filesystem identity; identical contents do not imply the same file.
func diff(prev, next map[string]fileState) []events.Event { func diff(prev, next map[string]fileState) []events.Event {
var evs []events.Event var evs []events.Event
structural := false structural := false
@@ -184,7 +184,7 @@ func diff(prev, next map[string]fileState) []events.Event {
structural = true structural = true
removed[p] = old removed[p] = old
} }
candidates := map[string]string{} // 新路径 -> 唯一的原路径 candidates := map[string]string{} // New path -> unique original path.
destinations := map[string]int{} destinations := map[string]int{}
for p, cur := range next { for p, cur := range next {
old, existed := prev[p] old, existed := prev[p]
@@ -206,7 +206,7 @@ func diff(prev, next map[string]fileState) []events.Event {
case old.dir != cur.dir: case old.dir != cur.dir:
structural = true structural = true
case !cur.dir && cur.rev == "" && (old.size != cur.size || old.mtime != cur.mtime || !os.SameFile(old.info, cur.info)): case !cur.dir && cur.rev == "" && (old.size != cur.size || old.mtime != cur.mtime || !os.SameFile(old.info, cur.info)):
// 图片没有正文版本号;替换后仍需刷新列表元数据与预览。 // Images lack text revisions; replacements must still refresh list metadata and previews.
if vault.IsImage(p) { if vault.IsImage(p) {
structural = true structural = true
} }
@@ -229,7 +229,7 @@ func diff(prev, next map[string]fileState) []events.Event {
move.From = parent.Path + strings.TrimPrefix(move.From, parent.From) move.From = parent.Path + strings.TrimPrefix(move.From, parent.From)
} }
} }
// 先应用父目录映射;子项另有改名时,从映射后的路径继续移动。 // Apply parent-directory mappings first; independently renamed children then move from their mapped paths.
if move.From != move.Path { if move.From != move.Path {
emitted = append(emitted, move) emitted = append(emitted, move)
} }
+13 -13
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 一键启动开发环境:Go 后端(go run -tags dev)+ Vite 前端(热更新),Ctrl+C 同时退出。 # Start Go (go run -tags dev) and Vite with hot updates; Ctrl+C stops both.
# #
# 用法:scripts/dev.sh [--host] # Usage: scripts/dev.sh [--host]
# --host 前端监听所有网卡,便于手机/平板通过局域网访问 # --host Listen on all interfaces for LAN access from phones/tablets.
# #
# 环境变量: # Environment variables:
# RAWMARK_DATA 开发数据目录,默认 <仓库>/.dev-data # RAWMARK_DATA Development data directory; defaults to <repository>/.dev-data.
# API_PORT 后端端口,默认 8080(Vite 代理读取同一变量) # API_PORT Backend port; defaults to 8080 (also used by the Vite proxy).
# WEB_PORT 前端端口,默认 5173 # WEB_PORT Frontend port; defaults to 5173.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -32,7 +32,7 @@ for arg in "$@"; do
esac esac
done done
# Go 默认安装位置可能不在非登录 shell 的 PATH 中 # The default Go installation may be missing from PATH in non-login shells.
[[ -d /usr/local/go/bin ]] && export PATH="$PATH:/usr/local/go/bin" [[ -d /usr/local/go/bin ]] && export PATH="$PATH:/usr/local/go/bin"
fail() { fail() {
@@ -54,11 +54,11 @@ port_in_use() {
port_in_use "$API_PORT" && fail "端口 $API_PORT 已被占用(后端)" port_in_use "$API_PORT" && fail "端口 $API_PORT 已被占用(后端)"
port_in_use "$WEB_PORT" && fail "端口 $WEB_PORT 已被占用(前端)" port_in_use "$WEB_PORT" && fail "端口 $WEB_PORT 已被占用(前端)"
# 由 pnpm 核对锁定依赖;已有依赖时复用本地缓存。 # Let pnpm verify locked dependencies and reuse cached packages when available.
echo "==> 核对前端依赖" echo "==> 核对前端依赖"
pnpm -C web install --frozen-lockfile pnpm -C web install --frozen-lockfile
# 退出时结束本脚本启动的所有子进程(包括 go run 编译出的服务进程) # On exit, stop every child started here, including the service compiled by go run.
cleanup() { cleanup() {
trap - EXIT INT TERM trap - EXIT INT TERM
echo echo
@@ -67,13 +67,13 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
# 给两路日志加前缀,便于区分 # Prefix both log streams to distinguish them.
prefix() { sed -u "s/^/[$1] /"; } prefix() { sed -u "s/^/[$1] /"; }
echo "==> 启动后端 http://127.0.0.1:$API_PORT(数据目录 $RAWMARK_DATA)" echo "==> 启动后端 http://127.0.0.1:$API_PORT(数据目录 $RAWMARK_DATA)"
RAWMARK_ADDR="127.0.0.1:$API_PORT" go run -tags dev ./cmd/rawmark 2>&1 | prefix server & RAWMARK_ADDR="127.0.0.1:$API_PORT" go run -tags dev ./cmd/rawmark 2>&1 | prefix server &
# 等后端就绪后再启动前端,避免首次编译期间前端请求失败 # Wait for the backend before starting Vite to avoid failed requests during initial compilation.
for _ in $(seq 1 120); do for _ in $(seq 1 120); do
curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 && break curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 && break
sleep 0.5 sleep 0.5
@@ -83,5 +83,5 @@ curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 || fail "后
echo "==> 启动前端 http://localhost:$WEB_PORT" echo "==> 启动前端 http://localhost:$WEB_PORT"
(cd web && exec pnpm exec vite "${VITE_ARGS[@]}") 2>&1 | prefix web & (cd web && exec pnpm exec vite "${VITE_ARGS[@]}") 2>&1 | prefix web &
# 任一进程退出(如后端崩溃)即整体停止 # Stop everything if either process exits, such as after a backend crash.
wait -n wait -n
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""从指定 v7.9 ZIP 重建本地字体:python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip。 """Rebuild local fonts from the specified v7.9 ZIP: python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip.
仅重建字体时需要 fonttools[woff];应用构建直接使用已入库的 WOFF2。 Only font regeneration requires fonttools[woff]; application builds use the checked-in WOFF2 files.
来源:https://github.com/subframe7536/maple-font/releases/tag/v7.9 Source: https://github.com/subframe7536/maple-font/releases/tag/v7.9
""" """
import io import io
from pathlib import Path from pathlib import Path
@@ -15,14 +15,14 @@ root = Path(__file__).resolve().parents[1] / 'web/src/common/appearance'
output = root / 'fonts/maple' output = root / 'fonts/maple'
output.mkdir(exist_ok=True) output.mkdir(exist_ok=True)
family = 'Maple Mono Normal NL NF CN' family = 'Maple Mono Normal NL NF CN'
css = ['/* Maple Mono v7.9;由 scripts/prepare-maple-font.py 生成,授权见 fonts/maple/LICENSE.txt。 */'] css = ['/* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */']
with zipfile.ZipFile(sys.argv[1]) as archive: with zipfile.ZipFile(sys.argv[1]) as archive:
(output / 'LICENSE.txt').write_bytes(archive.read('LICENSE.txt')) (output / 'LICENSE.txt').write_bytes(archive.read('LICENSE.txt'))
for variant, weight, style in [('Regular', 400, 'normal'), ('Bold', 700, 'normal'), ('Italic', 400, 'italic'), ('BoldItalic', 700, 'italic')]: for variant, weight, style in [('Regular', 400, 'normal'), ('Bold', 700, 'normal'), ('Italic', 400, 'italic'), ('BoldItalic', 700, 'italic')]:
data = archive.read('MapleMonoNormalNL-NF-CN-' + variant + '.ttf') data = archive.read('MapleMonoNormalNL-NF-CN-' + variant + '.ttf')
source = TTFont(io.BytesIO(data)) source = TTFont(io.BytesIO(data))
points = sorted(source.getBestCmap()) points = sorted(source.getBestCmap())
# 按 Unicode 区间分片,中文和 Nerd Font 不强制随拉丁文字一起下载。 # Split by Unicode range so Chinese and Nerd Font glyphs need not download alongside Latin text.
for block in sorted({cp // 4096 for cp in points}): for block in sorted({cp // 4096 for cp in points}):
unicodes = [cp for cp in points if cp // 4096 == block] unicodes = [cp for cp in points if cp // 4096 == block]
font = TTFont(io.BytesIO(data), recalcTimestamp=False) font = TTFont(io.BytesIO(data), recalcTimestamp=False)
+2 -2
View File
@@ -1,5 +1,5 @@
-- 一次性部署清理:先停止旧服务,并保留数据库与镜像配套的回滚备份。 -- One-time deployment cleanup: stop the old service and retain matching database/image rollback backups first.
-- 不由应用启动流程执行;原始笔记和图片不在此脚本的清理范围内。 -- Never run at application startup; original notes and images are outside this script's cleanup scope.
BEGIN IMMEDIATE; BEGIN IMMEDIATE;
DROP TABLE IF EXISTS share_items; DROP TABLE IF EXISTS share_items;
DROP TABLE IF EXISTS shares; DROP TABLE IF EXISTS shares;
+1 -1
View File
@@ -1,4 +1,4 @@
-- 仅在切换到 Typora 默认快捷键的部署中执行一次;保留所有其他设置。 -- Run once only when migrating to Typora default shortcuts; retain all other settings.
BEGIN IMMEDIATE; BEGIN IMMEDIATE;
UPDATE settings SET value = json_remove(value, '$.shortcuts') WHERE key = 'app.settings'; UPDATE settings SET value = json_remove(value, '$.shortcuts') WHERE key = 'app.settings';
COMMIT; COMMIT;
+10 -10
View File
@@ -1,24 +1,24 @@
# 配置项文档:https://editorconfig.org(修改配置后重启编辑器) # Configuration reference: https://editorconfig.org (restart the editor after changes).
## 告知 EditorConfig 插件,当前即是根文件 ## Mark this file as the EditorConfig root.
root = true root = true
## 适用全部文件 ## Apply to all files.
[*] [*]
### 设置字符集 ### Character encoding.
charset = utf-8 charset = utf-8
### 缩进风格 space | tab,建议 space ### Indentation style: space | tab; prefer spaces.
indent_style = space indent_style = space
### 缩进的空格数 ### Spaces per indentation level.
indent_size = 2 indent_size = 2
### 换行符类型 lf | cr | crlf,一般都是设置为 lf ### Line endings: lf | cr | crlf; normally use lf.
end_of_line = lf end_of_line = lf
### 是否在文件末尾插入空白行 ### Ensure a final newline.
insert_final_newline = true insert_final_newline = true
### 是否删除一行中的前后空格 ### Remove trailing whitespace.
trim_trailing_whitespace = true trim_trailing_whitespace = true
## 适用 .md 文件 ## Apply to Markdown files.
[*.md] [*.md]
insert_final_newline = false insert_final_newline = false
trim_trailing_whitespace = false trim_trailing_whitespace = false
+1 -1
View File
@@ -1,5 +1,5 @@
# China mirror of npm # China mirror of npm
registry = https://registry.npmmirror.com registry = https://registry.npmmirror.com
# 安装依赖时锁定版本号 # Pin exact dependency versions on installation.
save-exact = true save-exact = true
+4 -4
View File
@@ -1,7 +1,7 @@
//go:build !dev //go:build !dev
// Package web 把前端构建产物(web/dist,由 pnpm build 生成)嵌入 Go 二进制。 // Package web embeds the frontend build (web/dist, generated by pnpm build) in the Go binary.
// 开发时使用 -tags dev,前端由 Vite 开发服务器提供,见 embed_dev.go。 // Use -tags dev during development; Vite serves the frontend, as described in embed_dev.go.
package web package web
import ( import (
@@ -17,12 +17,12 @@ var dist embed.FS
func Dist() fs.FS { func Dist() fs.FS {
sub, err := fs.Sub(dist, "dist") sub, err := fs.Sub(dist, "dist")
if err != nil { if err != nil {
panic(err) // 路径为编译期常量,不会失败 panic(err) // The compile-time constant path cannot fail.
} }
return sub return sub
} }
// BuildVersion 与页面入口使用同一构建标识,生产产物缺失时明确失败。 // BuildVersion shares the page entry's build identifier and fails explicitly when production output is missing.
func BuildVersion() (string, error) { func BuildVersion() (string, error) {
html, err := fs.ReadFile(Dist(), "index.html") html, err := fs.ReadFile(Dist(), "index.html")
if err != nil { if err != nil {
+4 -4
View File
@@ -9,16 +9,16 @@ import (
"regexp" "regexp"
) )
// Dist 在开发模式下直接读取磁盘上的 web/dist(未构建时为空,页面由 Vite 开发服务器提供), // Dist reads web/dist from disk in development (empty before building; Vite serves pages),
// 因此无需先构建前端即可 go run // so go run does not require a frontend build first.
func Dist() fs.FS { func Dist() fs.FS {
return os.DirFS("web/dist") return os.DirFS("web/dist")
} }
// 开发环境由 Vite 提供页面,不依赖 web/dist 中可能过期的构建产物。 // Vite serves development pages without relying on potentially stale web/dist output.
func BuildVersion() (string, error) { return "dev", nil } func BuildVersion() (string, error) { return "dev", nil }
// FontStorage 按 Vite 构建使用的 fonts.css 统计可选字体,不包含 Maple 与 KaTeX。 // FontStorage counts optional fonts from fonts.css as used by Vite, excluding Maple and KaTeX.
func FontStorage() (int64, error) { func FontStorage() (int64, error) {
root := filepath.Join("web", "src", "common", "appearance") root := filepath.Join("web", "src", "common", "appearance")
css, err := os.ReadFile(filepath.Join(root, "fonts.css")) css, err := os.ReadFile(filepath.Join(root, "fonts.css"))
+9 -9
View File
@@ -1,26 +1,26 @@
import antfu from '@antfu/eslint-config' import antfu from '@antfu/eslint-config'
// 更多自定义配置可查阅仓库:https://github.com/antfu/eslint-config // For more custom configuration, see https://github.com/antfu/eslint-config.
export default antfu( export default antfu(
{ {
// 使用外部格式化程序格式化 css、html、markdown 等文件 // Use external formatters for CSS, HTML, Markdown, and similar files.
formatters: true, formatters: true,
// 启用样式规则 // Enable stylistic rules.
stylistic: { stylistic: {
// 缩进级别 // Indentation width.
indent: 2, indent: 2,
// 引号风格 'single' | 'double' // Quote style: 'single' | 'double'.
quotes: 'single', quotes: 'single',
// 是否启用分号 // Whether to require semicolons.
semi: false, semi: false,
}, },
// 忽略文件 // Ignored files.
ignores: ['dist/**', 'types/auto/**', 'public/**', 'src/common/appearance/fonts/**', 'src/common/appearance/maple-font.css', 'LICENSE'], ignores: ['dist/**', 'types/auto/**', 'public/**', 'src/common/appearance/fonts/**', 'src/common/appearance/maple-font.css', 'LICENSE'],
}, },
{ {
// 对所有文件都生效的规则 // Rules applied to all files.
rules: { rules: {
// 保留项目现有紧凑分支及浏览器原生危险操作确认。 // Preserve existing compact branches and native browser confirmation for destructive actions.
'style/max-statements-per-line': 'off', 'style/max-statements-per-line': 'off',
'no-alert': 'off', 'no-alert': 'off',
// vue // vue
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"io/fs" "io/fs"
) )
// FontStorage 读取构建工具统计的内置可选字体大小,不包含默认 Maple。 // FontStorage reads build-generated sizes for optional built-in fonts, excluding default Maple.
func FontStorage() (int64, error) { func FontStorage() (int64, error) {
data, err := fs.ReadFile(Dist(), "font-storage.json") data, err := fs.ReadFile(Dist(), "font-storage.json")
if err != nil { if err != nil {
+1 -1
View File
@@ -4,7 +4,7 @@ allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true
esbuild: true esbuild: true
# CodeMirror/Lezer 类型和扩展必须共享同一实例,与迁移前版本保持一致。 # CodeMirror/Lezer types and extensions must share one instance, preserving pre-migration versions.
overrides: overrides:
'@codemirror/commands': 6.11.1 '@codemirror/commands': 6.11.1
'@codemirror/lang-markdown': 6.5.2 '@codemirror/lang-markdown': 6.5.2
+1 -1
View File
@@ -10,7 +10,7 @@ import { navigationError, router } from '@/router'
const user = useUserStore() const user = useUserStore()
function retry() { function retry() {
const { path, query, hash } = router.resolve(navigationError.value!.path) const { path, query, hash } = router.resolve(navigationError.value!.path)
void router.replace({ path, query, hash, force: true }).catch(() => { /* 错误由路由错误界面展示。 */ }) void router.replace({ path, query, hash, force: true }).catch(() => { /* The router error interface displays failures. */ })
} }
</script> </script>
+5 -5
View File
@@ -2,7 +2,7 @@ import type * as Assets from './type'
import { encodePath } from '@@/utils/paths' import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 上传笔记图片。 */ /** Upload an image for a note. */
export function uploadImageApi(data: Assets.UploadImageRequestData) { export function uploadImageApi(data: Assets.UploadImageRequestData) {
const body = new FormData() const body = new FormData()
body.append('file', data.file, data.file.name || 'image') body.append('file', data.file, data.file.name || 'image')
@@ -15,7 +15,7 @@ export function uploadImageApi(data: Assets.UploadImageRequestData) {
}) })
} }
/** 上传目录图片。 */ /** Upload an image for a folder. */
export function uploadFolderImageApi(data: Assets.FolderImageRequestData) { export function uploadFolderImageApi(data: Assets.FolderImageRequestData) {
const body = new FormData() const body = new FormData()
body.append('file', data.file) body.append('file', data.file)
@@ -28,7 +28,7 @@ export function uploadFolderImageApi(data: Assets.FolderImageRequestData) {
}) })
} }
/** 替换原路径图片。 */ /** Replace an image at its existing path. */
export function replaceImageApi(data: Assets.ReplaceImageRequestData) { export function replaceImageApi(data: Assets.ReplaceImageRequestData) {
const body = new FormData() const body = new FormData()
body.append('file', data.file) body.append('file', data.file)
@@ -40,7 +40,7 @@ export function replaceImageApi(data: Assets.ReplaceImageRequestData) {
}) })
} }
/** 下载并导入远程图片。 */ /** Download and import a remote image. */
export function importImageApi(data: Assets.ImportImageRequestData) { export function importImageApi(data: Assets.ImportImageRequestData) {
return request<Assets.ImageResponseData>({ return request<Assets.ImageResponseData>({
url: 'assets/import', url: 'assets/import',
@@ -51,7 +51,7 @@ export function importImageApi(data: Assets.ImportImageRequestData) {
}) })
} }
/** 核对引用后清理图片。 */ /** Clean up images after checking references. */
export function cleanupImagesApi(data: Assets.CleanupImagesRequestData) { export function cleanupImagesApi(data: Assets.CleanupImagesRequestData) {
return request<Assets.CleanupImagesResponseData>({ return request<Assets.CleanupImagesResponseData>({
url: 'assets/cleanup', url: 'assets/cleanup',
+8 -8
View File
@@ -1,7 +1,7 @@
import type * as Auth from './type' import type * as Auth from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 查询初始化状态。 */ /** Query initialization status. */
export function getAuthStateApi() { export function getAuthStateApi() {
return request<Auth.StateResponseData>({ return request<Auth.StateResponseData>({
url: 'auth/state', url: 'auth/state',
@@ -11,7 +11,7 @@ export function getAuthStateApi() {
}) })
} }
/** 签发或刷新登录验证码。 */ /** Issue or refresh a sign-in captcha. */
export function getCaptchaApi(data: Auth.CaptchaRequestData) { export function getCaptchaApi(data: Auth.CaptchaRequestData) {
return request<Auth.CaptchaResponseData>({ return request<Auth.CaptchaResponseData>({
url: 'auth/captcha', url: 'auth/captcha',
@@ -22,7 +22,7 @@ export function getCaptchaApi(data: Auth.CaptchaRequestData) {
}) })
} }
/** 初始化登录密码。 */ /** Initialize the login password. */
export function setupApi(data: Auth.SetupRequestData) { export function setupApi(data: Auth.SetupRequestData) {
return request<Auth.LoginResponseData>({ return request<Auth.LoginResponseData>({
url: 'auth/setup', url: 'auth/setup',
@@ -33,7 +33,7 @@ export function setupApi(data: Auth.SetupRequestData) {
}) })
} }
/** 使用密码和验证码登录。 */ /** Sign in with a password and captcha. */
export function loginApi(data: Auth.LoginRequestData) { export function loginApi(data: Auth.LoginRequestData) {
return request<Auth.LoginResponseData>({ return request<Auth.LoginResponseData>({
url: 'auth/login', url: 'auth/login',
@@ -44,7 +44,7 @@ export function loginApi(data: Auth.LoginRequestData) {
}) })
} }
/** 恢复当前身份与权限。 */ /** Restore the current identity and permissions. */
export function getCurrentUserApi() { export function getCurrentUserApi() {
return request<Auth.IdentityResponseData>({ return request<Auth.IdentityResponseData>({
url: 'auth/me', url: 'auth/me',
@@ -53,7 +53,7 @@ export function getCurrentUserApi() {
}) })
} }
/** 撤销当前服务端会话。 */ /** Revoke the current server session. */
export function logoutApi() { export function logoutApi() {
return request<void>({ return request<void>({
url: 'auth/logout', url: 'auth/logout',
@@ -61,7 +61,7 @@ export function logoutApi() {
}) })
} }
/** 签发受限资源票据。 */ /** Issue restricted resource tickets. */
export function createResourceTicketApi(data: Auth.TicketRequestData) { export function createResourceTicketApi(data: Auth.TicketRequestData) {
return request<Auth.TicketResponseData>({ return request<Auth.TicketResponseData>({
url: 'auth/tickets', url: 'auth/tickets',
@@ -71,7 +71,7 @@ export function createResourceTicketApi(data: Auth.TicketRequestData) {
}) })
} }
/** 修改登录密码。 */ /** Change the login password. */
export function changePasswordApi(data: Auth.PasswordRequestData) { export function changePasswordApi(data: Auth.PasswordRequestData) {
return request<Auth.PasswordResponseData>({ return request<Auth.PasswordResponseData>({
url: 'auth/password', url: 'auth/password',
+1 -1
View File
@@ -2,7 +2,7 @@ import type { EventHandlers } from './type'
import { createResourceTicketApi } from '@@/apis/auth' import { createResourceTicketApi } from '@@/apis/auth'
// 订阅仓库事件;每次连接领取一次性票据,重连后补全量同步。 // Subscribe to vault events; obtain a one-use ticket for each connection and fully sync after reconnecting.
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
+8 -8
View File
@@ -2,7 +2,7 @@ import type * as Files from './type'
import { encodePath } from '@@/utils/paths' import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取 Markdown 原文与版本。 */ /** Read original Markdown and its revision. */
export function getFileApi(path: string) { export function getFileApi(path: string) {
return request<string>({ return request<string>({
url: `files/${encodePath(path)}`, url: `files/${encodePath(path)}`,
@@ -13,7 +13,7 @@ export function getFileApi(path: string) {
}) })
} }
/** 按版本写入 Markdown。 */ /** Write Markdown with a revision precondition. */
export function writeFileApi(data: Files.WriteFileRequestData) { export function writeFileApi(data: Files.WriteFileRequestData) {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' } const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
if (data.ifMatch === '') headers['If-None-Match'] = '*' if (data.ifMatch === '') headers['If-None-Match'] = '*'
@@ -28,7 +28,7 @@ export function writeFileApi(data: Files.WriteFileRequestData) {
}) })
} }
/** 移入回收站。 */ /** Move an item to the recycle bin. */
export function deleteFileApi(path: string) { export function deleteFileApi(path: string) {
return request<Files.DeleteFileResponseData>({ return request<Files.DeleteFileResponseData>({
url: `files/${encodePath(path)}`, url: `files/${encodePath(path)}`,
@@ -37,7 +37,7 @@ export function deleteFileApi(path: string) {
}) })
} }
/** 创建文件夹。 */ /** Create a folder. */
export function createFolderApi(data: Files.FileRequestData) { export function createFolderApi(data: Files.FileRequestData) {
return request<Files.FileOperationResponseData>({ return request<Files.FileOperationResponseData>({
url: 'folders', url: 'folders',
@@ -47,7 +47,7 @@ export function createFolderApi(data: Files.FileRequestData) {
}) })
} }
/** 移动文件或目录。 */ /** Move a file or folder. */
export function moveFileApi(data: Files.MoveRequestData) { export function moveFileApi(data: Files.MoveRequestData) {
return request<Files.FileOperationResponseData>({ return request<Files.FileOperationResponseData>({
url: 'move', url: 'move',
@@ -57,7 +57,7 @@ export function moveFileApi(data: Files.MoveRequestData) {
}) })
} }
/** 导入 Markdown 或 ZIP。 */ /** Import Markdown or a ZIP archive. */
export function importNotesApi(data: Files.ImportRequestData) { export function importNotesApi(data: Files.ImportRequestData) {
const body = new FormData() const body = new FormData()
body.append('dir', data.dir) body.append('dir', data.dir)
@@ -71,7 +71,7 @@ export function importNotesApi(data: Files.ImportRequestData) {
}) })
} }
/** 导出选中内容为 Markdown ZIP。 */ /** Export selected contents as a Markdown ZIP. */
export function exportFilesApi(data: Files.ExportRequestData) { export function exportFilesApi(data: Files.ExportRequestData) {
return request<Blob>({ return request<Blob>({
url: 'export', url: 'export',
@@ -83,7 +83,7 @@ export function exportFilesApi(data: Files.ExportRequestData) {
}) })
} }
/** 下载原始文件。 */ /** Download a raw file. */
export function getRawFileApi(path: string) { export function getRawFileApi(path: string) {
return request<Blob>({ return request<Blob>({
url: `raw/${encodePath(path)}`, url: `raw/${encodePath(path)}`,
+13 -13
View File
@@ -1,7 +1,7 @@
import type * as Maintenance from './type' import type * as Maintenance from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取维护状态。 */ /** Read maintenance status. */
export function getMaintenanceStatusApi() { export function getMaintenanceStatusApi() {
return request<Maintenance.MaintenanceResponseData>({ return request<Maintenance.MaintenanceResponseData>({
url: 'maintenance/status', url: 'maintenance/status',
@@ -10,7 +10,7 @@ export function getMaintenanceStatusApi() {
}) })
} }
/** 导出实例或设置备份。 */ /** Export an instance or settings backup. */
export function exportBackupApi(kind: Maintenance.BackupKind) { export function exportBackupApi(kind: Maintenance.BackupKind) {
return request<Blob>({ return request<Blob>({
url: `maintenance/export/${kind}`, url: `maintenance/export/${kind}`,
@@ -21,7 +21,7 @@ export function exportBackupApi(kind: Maintenance.BackupKind) {
}) })
} }
/** 导出全部 Markdown 笔记。 */ /** Export all Markdown notes. */
export function exportNotesApi() { export function exportNotesApi() {
return request<Blob>({ return request<Blob>({
url: 'maintenance/notes/export', url: 'maintenance/notes/export',
@@ -32,7 +32,7 @@ export function exportNotesApi() {
}) })
} }
/** 导出系统日志。 */ /** Export system logs. */
export function exportLogsApi() { export function exportLogsApi() {
return request<Blob>({ return request<Blob>({
url: 'maintenance/logs/export', url: 'maintenance/logs/export',
@@ -43,7 +43,7 @@ export function exportLogsApi() {
}) })
} }
/** 创建备份上传。 */ /** Create a backup upload. */
export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) { export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) {
return request<Maintenance.BackupUploadResponseData>({ return request<Maintenance.BackupUploadResponseData>({
url: 'maintenance/uploads', url: 'maintenance/uploads',
@@ -53,7 +53,7 @@ export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData)
}) })
} }
/** 顺序上传备份分块。 */ /** Upload backup chunks sequentially. */
export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) { export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
return request<Maintenance.BackupChunkResponseData>({ return request<Maintenance.BackupChunkResponseData>({
url: `maintenance/uploads/${encodeURIComponent(data.id)}`, url: `maintenance/uploads/${encodeURIComponent(data.id)}`,
@@ -64,7 +64,7 @@ export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
}) })
} }
/** 校验完整备份。 */ /** Validate the complete backup. */
export function validateBackupApi(id: string) { export function validateBackupApi(id: string) {
return request<Maintenance.BackupSummaryResponseData>({ return request<Maintenance.BackupSummaryResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}/validate`, url: `maintenance/uploads/${encodeURIComponent(id)}/validate`,
@@ -73,7 +73,7 @@ export function validateBackupApi(id: string) {
}) })
} }
/** 取消备份上传并清理暂存。 */ /** Cancel a backup upload and remove staging files. */
export function cancelBackupUploadApi(id: string) { export function cancelBackupUploadApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({ return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}`, url: `maintenance/uploads/${encodeURIComponent(id)}`,
@@ -82,7 +82,7 @@ export function cancelBackupUploadApi(id: string) {
}) })
} }
/** 导入已确认的设置备份。 */ /** Import a confirmed settings backup. */
export function importSettingsApi(id: string) { export function importSettingsApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({ return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/import', url: 'maintenance/settings/import',
@@ -92,7 +92,7 @@ export function importSettingsApi(id: string) {
}) })
} }
/** 恢复已确认的实例备份。 */ /** Restore a confirmed instance backup. */
export function restoreBackupApi(id: string) { export function restoreBackupApi(id: string) {
return request<Maintenance.RestoreBackupResponseData>({ return request<Maintenance.RestoreBackupResponseData>({
url: 'maintenance/restore', url: 'maintenance/restore',
@@ -102,7 +102,7 @@ export function restoreBackupApi(id: string) {
}) })
} }
/** 备份后重置设置。 */ /** Back up and reset settings. */
export function resetSettingsApi() { export function resetSettingsApi() {
return request<Maintenance.MaintenanceOperationResponseData>({ return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/reset', url: 'maintenance/settings/reset',
@@ -112,7 +112,7 @@ export function resetSettingsApi() {
}) })
} }
/** 优化或重建派生索引。 */ /** Optimize or rebuild the derived index. */
export function maintainIndexApi(operation: 'optimize' | 'rebuild') { export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
return request<Maintenance.MaintenanceOperationResponseData>({ return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/index/${operation}`, url: `maintenance/index/${operation}`,
@@ -121,7 +121,7 @@ export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
}) })
} }
/** 清理非活动临时文件。 */ /** Clean inactive temporary files. */
export function clearTempApi() { export function clearTempApi() {
return request<Maintenance.ClearTempResponseData>({ return request<Maintenance.ClearTempResponseData>({
url: 'maintenance/temp/clear', url: 'maintenance/temp/clear',
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as Metadata from './type' import type * as Metadata from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取笔记元数据索引。 */ /** Read indexed note metadata. */
export function getMetadataApi() { export function getMetadataApi() {
return request<Metadata.MetadataResponseData>({ return request<Metadata.MetadataResponseData>({
url: 'metadata', url: 'metadata',
+6 -6
View File
@@ -1,7 +1,7 @@
import type * as Plugins from './type' import type * as Plugins from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取已安装插件。 */ /** List installed plugins. */
export function getPluginsApi() { export function getPluginsApi() {
return request<Plugins.PluginsResponseData>({ return request<Plugins.PluginsResponseData>({
url: 'plugins', url: 'plugins',
@@ -10,7 +10,7 @@ export function getPluginsApi() {
}) })
} }
/** 安装插件 ZIP。 */ /** Install a plugin ZIP. */
export function installPluginApi(file: File) { export function installPluginApi(file: File) {
const data = new FormData() const data = new FormData()
data.append('file', file) data.append('file', file)
@@ -22,7 +22,7 @@ export function installPluginApi(file: File) {
}) })
} }
/** 启用或停用插件。 */ /** Enable or disable a plugin. */
export function togglePluginApi(data: Plugins.TogglePluginRequestData) { export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
return request<Plugins.PluginResponseData>({ return request<Plugins.PluginResponseData>({
url: `plugins/${encodeURIComponent(data.id)}`, url: `plugins/${encodeURIComponent(data.id)}`,
@@ -32,7 +32,7 @@ export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
}) })
} }
/** 卸载插件并保留插件数据。 */ /** Uninstall a plugin while retaining its data. */
export function uninstallPluginApi(id: string) { export function uninstallPluginApi(id: string) {
return request<Plugins.PluginOperationResponseData>({ return request<Plugins.PluginOperationResponseData>({
url: `plugins/${encodeURIComponent(id)}`, url: `plugins/${encodeURIComponent(id)}`,
@@ -41,7 +41,7 @@ export function uninstallPluginApi(id: string) {
}) })
} }
/** 读取插件存储。 */ /** Read plugin storage. */
export function getPluginDataApi(id: string) { export function getPluginDataApi(id: string) {
return request<Plugins.PluginDataResponseData>({ return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(id)}/data`, url: `plugins/${encodeURIComponent(id)}/data`,
@@ -50,7 +50,7 @@ export function getPluginDataApi(id: string) {
}) })
} }
/** 保存插件存储。 */ /** Save plugin storage. */
export function savePluginDataApi(data: Plugins.PluginDataRequestData) { export function savePluginDataApi(data: Plugins.PluginDataRequestData) {
return request<Plugins.PluginDataResponseData>({ return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(data.id)}/data`, url: `plugins/${encodeURIComponent(data.id)}/data`,
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as Search from './type' import type * as Search from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 搜索笔记。 */ /** Search notes. */
export function searchApi(params: Search.SearchRequestData) { export function searchApi(params: Search.SearchRequestData) {
return request<Search.SearchResponseData>({ return request<Search.SearchResponseData>({
url: 'search', url: 'search',
+1 -1
View File
@@ -1,7 +1,7 @@
export interface SearchRequestData { q: string } export interface SearchRequestData { q: string }
export interface SearchResult { export interface SearchResult {
path: string path: string
/** 第一处匹配行,从1开始;只有路径匹配时为0。 */ /** First matching line, starting at 1; 0 when only the path matches. */
line: number line: number
snippet: string snippet: string
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import type * as Settings from './type' import type * as Settings from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取设置。 */ /** Read settings. */
export function getSettingsApi() { export function getSettingsApi() {
return request<Settings.SettingsResponseData>({ return request<Settings.SettingsResponseData>({
url: 'settings', url: 'settings',
@@ -10,7 +10,7 @@ export function getSettingsApi() {
}) })
} }
/** 更新设置。 */ /** Update settings. */
export function patchSettingsApi(data: Settings.SettingsData) { export function patchSettingsApi(data: Settings.SettingsData) {
return request<Settings.SettingsResponseData>({ return request<Settings.SettingsResponseData>({
url: 'settings', url: 'settings',
+1 -1
View File
@@ -1,3 +1,3 @@
/** 保留插件写入的未知设置,应用端按已知字段校验。 */ /** Preserve unknown plugin settings; the application validates known fields. */
export type SettingsData = Record<string, unknown> export type SettingsData = Record<string, unknown>
export type SettingsResponseData = ApiResponseData<SettingsData> export type SettingsResponseData = ApiResponseData<SettingsData>
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as Storage from './type' import type * as Storage from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 统计实际存储占用。 */ /** Measure actual storage usage. */
export function getStorageApi() { export function getStorageApi() {
return request<Storage.StorageResponseData>({ return request<Storage.StorageResponseData>({
url: 'storage', url: 'storage',
+2 -2
View File
@@ -1,7 +1,7 @@
import type * as Themes from './type' import type * as Themes from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取可选主题。 */ /** List available themes. */
export function getThemesApi() { export function getThemesApi() {
return request<Themes.ThemesResponseData>({ return request<Themes.ThemesResponseData>({
url: 'themes', url: 'themes',
@@ -10,7 +10,7 @@ export function getThemesApi() {
}) })
} }
/** 读取主题与样式。 */ /** Read a theme and its styles. */
export function getThemeApi(id: string) { export function getThemeApi(id: string) {
return request<Themes.ThemeResponseData>({ return request<Themes.ThemeResponseData>({
url: `themes/${encodeURIComponent(id)}`, url: `themes/${encodeURIComponent(id)}`,
+3 -3
View File
@@ -1,7 +1,7 @@
import type * as Tokens from './type' import type * as Tokens from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取 API 令牌列表。 */ /** List API tokens. */
export function getTokensApi() { export function getTokensApi() {
return request<Tokens.TokensResponseData>({ return request<Tokens.TokensResponseData>({
url: 'tokens', url: 'tokens',
@@ -10,7 +10,7 @@ export function getTokensApi() {
}) })
} }
/** 创建 API 令牌。 */ /** Create an API token. */
export function createTokenApi(data: Tokens.CreateTokenRequestData) { export function createTokenApi(data: Tokens.CreateTokenRequestData) {
return request<Tokens.CreateTokenResponseData>({ return request<Tokens.CreateTokenResponseData>({
url: 'tokens', url: 'tokens',
@@ -20,7 +20,7 @@ export function createTokenApi(data: Tokens.CreateTokenRequestData) {
}) })
} }
/** 撤销 API 令牌。 */ /** Revoke an API token. */
export function deleteTokenApi(id: string) { export function deleteTokenApi(id: string) {
return request<Tokens.DeleteTokenResponseData>({ return request<Tokens.DeleteTokenResponseData>({
url: `tokens/${encodeURIComponent(id)}`, url: `tokens/${encodeURIComponent(id)}`,
+4 -4
View File
@@ -1,7 +1,7 @@
import type * as Trash from './type' import type * as Trash from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取回收站。 */ /** Read the recycle bin. */
export function getTrashApi() { export function getTrashApi() {
return request<Trash.TrashResponseData>({ return request<Trash.TrashResponseData>({
url: 'trash', url: 'trash',
@@ -10,7 +10,7 @@ export function getTrashApi() {
}) })
} }
/** 恢复回收站条目。 */ /** Restore a recycle-bin entry. */
export function restoreTrashApi(id: string) { export function restoreTrashApi(id: string) {
return request<Trash.RestoreTrashResponseData>({ return request<Trash.RestoreTrashResponseData>({
url: `trash/${encodeURIComponent(id)}/restore`, url: `trash/${encodeURIComponent(id)}/restore`,
@@ -19,7 +19,7 @@ export function restoreTrashApi(id: string) {
}) })
} }
/** 清空回收站并返回逐项结果。 */ /** Empty the recycle bin and return per-entry results. */
export function clearTrashApi() { export function clearTrashApi() {
return request<Trash.ClearTrashResponseData>({ return request<Trash.ClearTrashResponseData>({
url: 'trash', url: 'trash',
@@ -28,7 +28,7 @@ export function clearTrashApi() {
}) })
} }
/** 永久删除回收站条目。 */ /** Permanently delete a recycle-bin entry. */
export function purgeTrashApi(id: string) { export function purgeTrashApi(id: string) {
return request<Trash.PurgeTrashResponseData>({ return request<Trash.PurgeTrashResponseData>({
url: `trash/${encodeURIComponent(id)}`, url: `trash/${encodeURIComponent(id)}`,
+2 -2
View File
@@ -1,7 +1,7 @@
import type * as Tree from './type' import type * as Tree from './type'
import { request } from '@/http/axios' import { request } from '@/http/axios'
/** 读取目录树。 */ /** Read the directory tree. */
export function getTreeApi() { export function getTreeApi() {
return request<Tree.TreeResponseData>({ return request<Tree.TreeResponseData>({
url: 'tree', url: 'tree',
@@ -10,7 +10,7 @@ export function getTreeApi() {
}) })
} }
/** 设置或重置目录项图标。 */ /** Set or reset a tree entry's icon. */
export function setTreeIconApi(data: Tree.TreeIconRequestData) { export function setTreeIconApi(data: Tree.TreeIconRequestData) {
return request<Tree.TreeIconResponseData>({ return request<Tree.TreeIconResponseData>({
url: 'tree-icon', url: 'tree-icon',
+1 -1
View File
@@ -56,7 +56,7 @@ function render() {
root.dataset.rmColorMode = dark ? 'dark' : 'light' root.dataset.rmColorMode = dark ? 'dark' : 'light'
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
style('rm-theme', appearancePaused ? '' : themeCSS) style('rm-theme', appearancePaused ? '' : themeCSS)
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。 // Separate style elements prevent unclosed syntax in one snippet from swallowing others.
document.querySelectorAll('style[data-rm-snippet]').forEach(el => el.remove()) document.querySelectorAll('style[data-rm-snippet]').forEach(el => el.remove())
if (!appearancePaused) { if (!appearancePaused) {
for (const snippet of current.cssSnippets) { for (const snippet of current.cssSnippets) {
+18 -18
View File
@@ -2,7 +2,7 @@
@import 'highlight.js/styles/atom-one-dark.css'; @import 'highlight.js/styles/atom-one-dark.css';
@import './maple-font.css'; @import './maple-font.css';
/* 基础主题。--rm-* 变量继续作为主题接口,字体在本地加载。 */ /* Base theme. --rm-* variables remain the theme interface; fonts load locally. */
:root { :root {
color-scheme: light; color-scheme: light;
--rm-sidebar-width: 288px; --rm-sidebar-width: 288px;
@@ -10,7 +10,7 @@
--rm-font-heading: var(--rm-font-editor, var(--rm-font-text)); --rm-font-heading: var(--rm-font-editor, var(--rm-font-text));
--rm-font-mono: 'Maple Mono Normal NL NF CN', monospace; --rm-font-mono: 'Maple Mono Normal NL NF CN', monospace;
--rm-font-size: 16px; --rm-font-size: 16px;
/* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */ /* Interface typography is independent of body text; secondary text scales with the interface but stays at least 13px. */
--rm-ui-font-size: 14px; --rm-ui-font-size: 14px;
--rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px)); --rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px)); --rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
@@ -159,7 +159,7 @@ textarea {
background: var(--rm-selection) !important; background: var(--rm-selection) !important;
} }
/* 标题 */ /* Headings. */
.cm-line:is(.cm-rm-h1, .cm-rm-h2, .cm-rm-h3, .cm-rm-h4, .cm-rm-h5, .cm-rm-h6) { .cm-line:is(.cm-rm-h1, .cm-rm-h2, .cm-rm-h3, .cm-rm-h4, .cm-rm-h5, .cm-rm-h6) {
line-height: 1.4; line-height: 1.4;
padding-top: 1rem; padding-top: 1rem;
@@ -186,7 +186,7 @@ textarea {
font-size: 1em; font-size: 1em;
} }
/* Markdown 标记(激活行显示源码时) */ /* Markdown markers shown as source on active lines. */
.cm-rm-markup, .cm-rm-markup,
.cm-rm-url { .cm-rm-url {
color: var(--rm-text-muted); color: var(--rm-text-muted);
@@ -198,7 +198,7 @@ textarea {
cursor: text; cursor: text;
} }
/* 行内代码 */ /* Inline code. */
.cm-rm-inline-code { .cm-rm-inline-code {
font-family: var(--rm-font-mono); font-family: var(--rm-font-mono);
font-size: 0.875em; font-size: 0.875em;
@@ -208,8 +208,8 @@ textarea {
border-radius: 4px; border-radius: 4px;
} }
/* 代码块 /* Code blocks. Paint backgrounds on ::before below the selection
* 背景画在 ::before 上并置于选区图层之下:若直接给 .cm-line 设背景,会盖住 CodeMirror 绘制的选区 */ * layer; a background on .cm-line would cover CodeMirror's selection. */
.cm-line.cm-rm-code { .cm-line.cm-rm-code {
position: relative; position: relative;
font-family: var(--rm-font-mono); font-family: var(--rm-font-mono);
@@ -246,7 +246,7 @@ textarea {
.cm-line.cm-rm-code-begin.cm-rm-code-end::before { .cm-line.cm-rm-code-begin.cm-rm-code-end::before {
border-radius: 8px; border-radius: 8px;
} }
/* 围栏展开前后保持同一行高,语言标签与复制按钮不参与行高计算,避免代码内容跳动。 */ /* Keep fence line heights unchanged when expanded; language labels and copy buttons must not affect line height or shift code. */
.cm-rm-code-header { .cm-rm-code-header {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -291,14 +291,14 @@ textarea {
color: var(--rm-text-muted); color: var(--rm-text-muted);
} }
/* 引用 */ /* Blockquotes. */
.cm-line.cm-rm-quote { .cm-line.cm-rm-quote {
border-left: 3px solid var(--rm-accent); border-left: 3px solid var(--rm-accent);
padding: 5px 15px; padding: 5px 15px;
background: color-mix(in srgb, var(--rm-accent) 4%, transparent); background: color-mix(in srgb, var(--rm-accent) 4%, transparent);
} }
/* 列表 */ /* Lists. */
.cm-rm-bullet { .cm-rm-bullet {
color: var(--rm-text-muted); color: var(--rm-text-muted);
display: inline-block; display: inline-block;
@@ -311,7 +311,7 @@ textarea {
cursor: pointer; cursor: pointer;
} }
/* 分隔线 */ /* Thematic breaks. */
.cm-rm-hr { .cm-rm-hr {
display: inline-block; display: inline-block;
width: 100%; width: 100%;
@@ -319,7 +319,7 @@ textarea {
vertical-align: middle; vertical-align: middle;
} }
/* 图片 */ /* Images. */
.cm-rm-image { .cm-rm-image {
display: block; display: block;
} }
@@ -332,7 +332,7 @@ textarea {
font-style: italic; font-style: italic;
} }
/* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */ /* Tables show monospaced source while the cursor is inside, otherwise an editable rendered table. */
.cm-line.cm-rm-table { .cm-line.cm-rm-table {
font-family: var(--rm-font-mono); font-family: var(--rm-font-mono);
font-size: 0.9em; font-size: 0.9em;
@@ -379,7 +379,7 @@ textarea {
.cm-rm-table-widget a { .cm-rm-table-widget a {
color: var(--rm-accent-strong); color: var(--rm-accent-strong);
} }
/* 工具栏占据表格上方的固定空间,只切换可见性,不遮挡表格也不引起跳动 */ /* Reserve fixed space above tables for the toolbar; toggle visibility without covering the table or shifting its position. */
.cm-rm-table-toolbar { .cm-rm-table-toolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -452,7 +452,7 @@ textarea {
.cm-editor .cm-content { .cm-editor .cm-content {
padding: 16px 16px 40vh; padding: 16px 16px 40vh;
} }
/* 触屏没有悬停,工具栏常显,按钮加大触控热区 */ /* Touchscreens lack hover: keep the toolbar visible and enlarge button touch targets. */
.cm-rm-table-toolbar { .cm-rm-table-toolbar {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
@@ -463,7 +463,7 @@ textarea {
} }
} }
/* ---- Typora 扩展语法 ---- */ /* ---- Typora syntax extensions ---- */
.cm-rm-highlight, .cm-rm-highlight,
.cm-rm-table-widget mark { .cm-rm-table-widget mark {
background: var(--rm-highlight-bg); background: var(--rm-highlight-bg);
@@ -505,7 +505,7 @@ textarea {
font-size: 0.9em; font-size: 0.9em;
color: var(--rm-text-muted); color: var(--rm-text-muted);
} }
/* 编辑时显示在源码下方的预览 */ /* Preview displayed below source while editing. */
.cm-rm-preview { .cm-rm-preview {
margin: 4px 0 8px; margin: 4px 0 8px;
padding: 8px 12px; padding: 8px 12px;
@@ -628,7 +628,7 @@ textarea {
} }
} }
/* opacity 保留排版和字体请求;等待期间不显示备用字体,也覆盖 Teleport 到 body 的菜单。 */ /* opacity preserves layout and font requests; hide fallback fonts while waiting, including menus teleported to body. */
:root:is([data-rm-fonts='loading'], [data-rm-fonts='error']) body > :not(.rm-font-error) { :root:is([data-rm-fonts='loading'], [data-rm-fonts='error']) body > :not(.rm-font-error) {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
+3 -3
View File
@@ -1,5 +1,5 @@
import { t as translate } from '@@/i18n/runtime' import { t as translate } from '@@/i18n/runtime'
// 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。 // Use the font set requested by actual browser layout, covering initial render, note switches, and later glyphs.
export function watchFontLoading() { export function watchFontLoading() {
const root = document.documentElement const root = document.documentElement
const fonts = document.fonts const fonts = document.fonts
@@ -22,7 +22,7 @@ export function watchFontLoading() {
function ready() { function ready() {
if (root.dataset.rmFonts === 'error') return if (root.dataset.rmFonts === 'error') return
// fonts.ready 在下载失败时也会完成,不能将失败当作字体已就绪。 // fonts.ready also resolves after download failure; failure must not count as fonts being ready.
if ([...fonts].some(font => font.status === 'error')) failed() if ([...fonts].some(font => font.status === 'error')) failed()
else if (fonts.status === 'loaded') root.dataset.rmFonts = 'ready' else if (fonts.status === 'loaded') root.dataset.rmFonts = 'ready'
} }
@@ -34,7 +34,7 @@ export function watchFontLoading() {
}) })
fonts.addEventListener('loadingerror', failed) fonts.addEventListener('loadingerror', failed)
// 挂载完成后触发一次排版,确保等待的是当前文字用到的字体。 // Trigger layout after mounting so the wait covers fonts used by the current text.
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.body.getBoundingClientRect() document.body.getBoundingClientRect()
void fonts.ready.then(ready) void fonts.ready.then(ready)
+1 -1
View File
@@ -13,7 +13,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
if (!style) { if (!style) {
style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style) style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style)
} }
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。 // Names are display-only; CSS identifiers and resource paths use only server-generated content hashes.
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('${fontResourceRoot()}${font.id}/file'); font-display: block; }`).join('\n') const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('${fontResourceRoot()}${font.id}/file'); font-display: block; }`).join('\n')
if (style.textContent !== css) style.textContent = css if (style.textContent !== css) style.textContent = css
} }
+1 -1
View File
@@ -1,4 +1,4 @@
/* Maple Mono v7.9;由 scripts/prepare-maple-font.py 生成,授权见 fonts/maple/LICENSE.txt。 */ /* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-00.woff2') format('woff2'); unicode-range: U+0-FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-00.woff2') format('woff2'); unicode-range: U+0-FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; }
+1 -1
View File
@@ -1,5 +1,5 @@
import { t as translate } from '@@/i18n/runtime' import { t as translate } from '@@/i18n/runtime'
// 编辑器排版设置;账户、快捷键等设置不进入此模块。 // Editor typography settings only; account, shortcuts, and other settings do not belong here.
const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' } const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' }
export const TEXT_FONTS = { export const TEXT_FONTS = {
maple, maple,
+28 -28
View File
@@ -1,9 +1,9 @@
/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */ /* Application shell styles; colors and fonts use --rm-* variables from common/appearance/base.css. */
html, html,
body { body {
height: 100%; height: 100%;
} }
/* 软键盘弹出时为可视区域高度,见 viewport.ts */ /* Use the visible viewport height when the software keyboard opens; see viewport.ts. */
#app { #app {
height: var(--rm-app-height, 100%); height: var(--rm-app-height, 100%);
display: flex; display: flex;
@@ -52,7 +52,7 @@ button {
color: inherit; color: inherit;
} }
/* ---- 通用控件 ---- */ /* ---- Shared controls ---- */
.rm-btn { .rm-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -121,7 +121,7 @@ button {
color: var(--rm-text-muted); color: var(--rm-text-muted);
} }
/* ---- 登录 ---- */ /* ---- Sign-in ---- */
.rm-auth { .rm-auth {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -159,7 +159,7 @@ button {
font-size: var(--rm-ui-font-small); font-size: var(--rm-ui-font-small);
} }
/* ---- 布局 ---- */ /* ---- Layout ---- */
.rm-shell { .rm-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -220,7 +220,7 @@ button {
transition: none; transition: none;
} }
/* ---- 大纲 ---- */ /* ---- Outline ---- */
.rm-outline-backdrop { .rm-outline-backdrop {
display: flex; display: flex;
flex: none; flex: none;
@@ -292,7 +292,7 @@ button {
display: none; display: none;
} }
/* ---- 侧栏 ---- */ /* ---- Sidebar ---- */
.rm-sidebar-inner { .rm-sidebar-inner {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
@@ -346,7 +346,7 @@ button {
--rm-tree-row-overlay: var(--rm-hover); --rm-tree-row-overlay: var(--rm-hover);
background: var(--rm-hover); background: var(--rm-hover);
} }
/* 长按由应用处理,不弹出 iOS 的系统菜单 */ /* The application handles long presses instead of showing the iOS system menu. */
.rm-tree-row { .rm-tree-row {
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
@@ -403,7 +403,7 @@ button {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
/* 按钮覆盖名称末尾,不为隐藏按钮留位;不透明底色遮住下方文字。 */ /* Buttons overlay the end of the name without reserving hidden space; an opaque background covers underlying text. */
.rm-tree-row .rm-tree-more { .rm-tree-row .rm-tree-more {
background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg); background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg);
} }
@@ -432,7 +432,7 @@ button {
font-size: var(--rm-ui-font-small); font-size: var(--rm-ui-font-small);
text-align: center; text-align: center;
} }
/* ---- 搜索 ---- */ /* ---- Search ---- */
.rm-search { .rm-search {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -515,7 +515,7 @@ button {
color: var(--rm-text); color: var(--rm-text);
border-radius: 2px; border-radius: 2px;
} }
/* ---- 快速切换 ---- */ /* ---- Quick switcher ---- */
.rm-switcher-list { .rm-switcher-list {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -583,7 +583,7 @@ button {
color: var(--rm-text); color: var(--rm-text);
} }
/* ---- 菜单 ---- */ /* ---- Menus ---- */
.rm-menu { .rm-menu {
position: absolute; position: absolute;
top: 100%; top: 100%;
@@ -626,7 +626,7 @@ button {
cursor: default; cursor: default;
} }
/* ---- 标签页 ---- */ /* ---- Tabs ---- */
.rm-tabs { .rm-tabs {
display: flex; display: flex;
gap: 2px; gap: 2px;
@@ -733,7 +733,7 @@ button {
border-radius: 50%; border-radius: 50%;
background: var(--rm-accent); background: var(--rm-accent);
} }
/* 有未保存修改时显示圆点,悬停时换回关闭图标 */ /* Show a dot for unsaved changes; replace it with the close icon on hover. */
.rm-tab-close.dirty .rm-dirty-dot { .rm-tab-close.dirty .rm-dirty-dot {
display: block; display: block;
} }
@@ -747,7 +747,7 @@ button {
opacity: 1 !important; opacity: 1 !important;
} }
/* ---- 提示条 ---- */ /* ---- Status bars ---- */
.rm-banner { .rm-banner {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -767,7 +767,7 @@ button {
color: var(--rm-warn-text); color: var(--rm-warn-text);
} }
/* ---- 编辑区 ---- */ /* ---- Editor area ---- */
.rm-editor-pane { .rm-editor-pane {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -780,7 +780,7 @@ button {
height: 100%; height: 100%;
} }
/* ---- 对话框 ---- */ /* ---- Dialogs ---- */
.rm-dialog-head { .rm-dialog-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -823,7 +823,7 @@ button {
font-size: var(--rm-ui-font-small); font-size: var(--rm-ui-font-small);
user-select: all; user-select: all;
} }
/* ---- 设置 ---- */ /* ---- Settings ---- */
.rm-setting { .rm-setting {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -849,7 +849,7 @@ button {
transform: rotate(180deg); transform: rotate(180deg);
} }
/* ---- 通知 ---- */ /* ---- Notifications ---- */
.rm-toasts { .rm-toasts {
position: fixed; position: fixed;
left: 50%; left: 50%;
@@ -883,7 +883,7 @@ button {
padding: 0; padding: 0;
} }
/* ---- 移动端 ---- */ /* ---- Mobile ---- */
@media (max-width: 767px) { @media (max-width: 767px) {
.rm-sidebar { .rm-sidebar {
position: fixed; position: fixed;
@@ -966,7 +966,7 @@ button {
.rm-sidebar-resize { .rm-sidebar-resize {
display: none; display: none;
} }
/* 大纲在手机上为从右侧滑入的浮层 */ /* On phones, the outline is an overlay sliding in from the right. */
.rm-outline-backdrop { .rm-outline-backdrop {
display: block; display: block;
position: fixed; position: fixed;
@@ -984,12 +984,12 @@ button {
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
box-shadow: var(--rm-shadow); box-shadow: var(--rm-shadow);
} }
/* 触控热区不小于 44px */ /* Touch targets must be at least 44px. */
.rm-tree-row { .rm-tree-row {
height: var(--rm-control-size); height: var(--rm-control-size);
font-size: inherit; font-size: inherit;
} }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */ /* iOS Safari zooms focused inputs when their font size is below 16px. */
.rm-search-input { .rm-search-input {
height: var(--rm-control-size); height: var(--rm-control-size);
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
@@ -1001,14 +1001,14 @@ button {
.rm-search-item { .rm-search-item {
padding: 10px 8px; padding: 10px 8px;
} }
/* 手机上从顶部弹出,给软键盘留出空间 */ /* Open from the top on phones to leave room for the software keyboard. */
.rm-switcher-item { .rm-switcher-item {
height: var(--rm-control-size); height: var(--rm-control-size);
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
} }
} }
/* 主题与 CSS 编辑 */ /* Theme and CSS editing. */
.rm-appearance-note { .rm-appearance-note {
margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4); margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4);
font-size: var(--rm-ui-font-small); font-size: var(--rm-ui-font-small);
@@ -1195,7 +1195,7 @@ button {
background: var(--rm-active); background: var(--rm-active);
} }
/* 轻玻璃工作台 */ /* Glass workspace. */
.rm-vault-heading { .rm-vault-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1348,7 +1348,7 @@ button {
min-height: 0; min-height: 0;
position: relative; position: relative;
} }
/* 选区工具栏与局部外观面板 */ /* Selection toolbar and local appearance panel. */
.rm-selection-popover { .rm-selection-popover {
position: fixed; position: fixed;
display: flex; display: flex;
@@ -1597,7 +1597,7 @@ button {
} }
} }
/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */ /* Tree children expand to their actual content height; exiting items must not remain clickable or focusable. */
.rm-tree-children { .rm-tree-children {
display: grid; display: grid;
grid-template-rows: 1fr; grid-template-rows: 1fr;
@@ -39,7 +39,7 @@
gap: inherit; gap: inherit;
} }
/* 复用 RawMark 主题,弹出层与页面使用相同的字体、色彩和密度。 */ /* Reuse the RawMark theme so popovers and pages share fonts, colors, and density. */
:root { :root {
--el-font-size-small: var(--rm-ui-font-small); --el-font-size-small: var(--rm-ui-font-small);
--el-font-size-extra-small: var(--rm-ui-font-small); --el-font-size-extra-small: var(--rm-ui-font-small);
@@ -20,7 +20,7 @@ async function update() {
return return
} }
if (!activated.value && worker) { if (!activated.value && worker) {
// 等待新版激活后,仅刷新主动点击更新的页面。 // After the new version activates, refresh only the page that requested the update.
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const finish = () => { const finish = () => {
if (worker.state !== 'activated' && worker.state !== 'redundant') return if (worker.state !== 'activated' && worker.state !== 'redundant') return
@@ -39,7 +39,7 @@ async function update() {
}) })
activated.value = true activated.value = true
} }
// 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。 // Typing may continue during activation; confirm again before refreshing and keep the button for retrying after save.
if (await props.beforeReload()) location.reload() if (await props.beforeReload()) location.reload()
else toast(translate('common.some_changes_or_operations_are_a5fda9')) else toast(translate('common.some_changes_or_operations_are_a5fda9'))
} catch { } catch {
@@ -67,7 +67,7 @@ async function importFont(event: Event) {
try { try {
if (file.size > 30 * 1024 * 1024) throw new Error(translate('common.font_files_must_not_exceed_cc001c')) if (file.size > 30 * 1024 * 1024) throw new Error(translate('common.font_files_must_not_exceed_cc001c'))
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error(translate('common.choose_a_ttf_otf_woff_eb6ab7')) if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error(translate('common.choose_a_ttf_otf_woff_eb6ab7'))
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。 // Let the browser validate the actual font; invalid fonts are neither uploaded nor added to the page's loading set.
const data = await file.arrayBuffer() const data = await file.arrayBuffer()
try { try {
await new FontFace('rawmark-import-check', data).load() await new FontFace('rawmark-import-check', data).load()
@@ -22,7 +22,7 @@ function onSystem() {
system.addEventListener('change', onSystem) system.addEventListener('change', onSystem)
onBeforeUnmount(() => system.removeEventListener('change', onSystem)) onBeforeUnmount(() => system.removeEventListener('change', onSystem))
const dark = computed(() => (fixedThemeMode(settings.theme) ?? (settings.colorMode === 'system' ? (systemDark.value ? 'dark' : 'light') : settings.colorMode)) === 'dark') const dark = computed(() => (fixedThemeMode(settings.theme) ?? (settings.colorMode === 'system' ? (systemDark.value ? 'dark' : 'light') : settings.colorMode)) === 'dark')
// 思源色板顺序;只影响选中文字,不修改主题背景。 // SiYuan palette order; affect only selected text, not the theme background.
const foreground = computed(() => dark.value const foreground = computed(() => dark.value
? ['#f39993', '#ffd599', '#a6d5fa', '#b7dfb9', '#9aa0a6', '#3575f0', '#f3a92f', '#d23f31', '#f47c9c', '#4dd0e1', '#10df54', '#ecd424', '#1e1e1e'] ? ['#f39993', '#ffd599', '#a6d5fa', '#b7dfb9', '#9aa0a6', '#3575f0', '#f3a92f', '#d23f31', '#f47c9c', '#4dd0e1', '#10df54', '#ecd424', '#1e1e1e']
: ['#790600', '#8a4f00', '#005599', '#006b04', '#5f6368', '#3575f0', '#ff9200', '#d23f31', '#a40074', '#007080', '#006e2a', '#6b5a00', '#ffffff']) : ['#790600', '#8a4f00', '#005599', '#006b04', '#5f6368', '#3575f0', '#ff9200', '#d23f31', '#a40074', '#007080', '#006e2a', '#6b5a00', '#ffffff'])
+1 -1
View File
@@ -8,7 +8,7 @@ const CACHE_LIMIT = 500
let worker: Worker | undefined let worker: Worker | undefined
let nextId = 1 let nextId = 1
const pending = new Map<number, { resolve: (tokens: TokenRun | null) => void, reject: (reason: unknown) => void }>() const pending = new Map<number, { resolve: (tokens: TokenRun | null) => void, reject: (reason: unknown) => void }>()
// 以"语言 + 代码"为键缓存结果;Map 保持插入顺序,超限时淘汰最早的条目 // Cache by "language + code"; Map preserves insertion order, so evict the oldest entry when the limit is exceeded.
const cache = new Map<string, TokenRun | null>() const cache = new Map<string, TokenRun | null>()
function getWorker(): Worker { function getWorker(): Worker {
+3 -3
View File
@@ -1,4 +1,4 @@
// 主线程与高亮 Worker 之间的消息格式 // Message format between the main thread and highlight Worker.
export interface HighlightRequest { export interface HighlightRequest {
id: number id: number
@@ -6,8 +6,8 @@ export interface HighlightRequest {
code: string code: string
} }
// 保留 highlight.js 的嵌套 token 与原文,编辑器使用官方 token 类样式。 // Preserve highlight.js nested tokens and source text; the editor uses official token-class styles.
export type TokenRun = (string | { classes: string, content: TokenRun })[] export type TokenRun = (string | { classes: string, content: TokenRun })[]
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。 // tokens is null for unsupported languages; actual failures must remain distinct from plain-text results.
export type HighlightResponse = { id: number, tokens: TokenRun | null } | { id: number, error: Error } export type HighlightResponse = { id: number, tokens: TokenRun | null } | { id: number, error: Error }
+1 -1
View File
@@ -1,4 +1,4 @@
/* eslint-disable no-template-curly-in-string -- 测试样例需要保留源码中的模板插值。 */ /* eslint-disable no-template-curly-in-string -- Test fixtures must retain source template interpolation. */
import type { HighlightResponse, TokenRun } from './protocol' import type { HighlightResponse, TokenRun } from './protocol'
import { afterAll, beforeAll, expect, it, vi } from 'vitest' import { afterAll, beforeAll, expect, it, vi } from 'vitest'
+2 -2
View File
@@ -1,7 +1,7 @@
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol' import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
import hljs from 'highlight.js' import hljs from 'highlight.js'
// 只转换 highlight.js 生成的 span 和转义文本,不向编辑器插入 HTML。 // Convert only highlight.js-generated spans and escaped text; never insert HTML into the editor.
function serialize(html: string): TokenRun { function serialize(html: string): TokenRun {
const root: TokenRun = [] const root: TokenRun = []
const stack = [root] const stack = [root]
@@ -23,7 +23,7 @@ function serialize(html: string): TokenRun {
function highlight(req: HighlightRequest): TokenRun | null { function highlight(req: HighlightRequest): TokenRun | null {
if (!hljs.getLanguage(req.lang)) return null if (!hljs.getLanguage(req.lang)) return null
const result = hljs.highlight(req.code, { language: req.lang, ignoreIllegals: true }) const result = hljs.highlight(req.code, { language: req.lang, ignoreIllegals: true })
// 库的安全模式会随纯文本结果返回异常,仍需传递真实失败原因。 // The library's safe mode may return an exception alongside plain text; propagate the actual failure.
if (result.errorRaised) throw result.errorRaised if (result.errorRaised) throw result.errorRaised
return serialize(result.value) return serialize(result.value)
} }
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more