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
docs/*
!docs/guide.md
@@ -11,14 +11,14 @@ docs/*
!docs/api.zh-CN.md
!docs/images/
# 前端
# Frontend
node_modules/
web/dist/
# 本地开发数据
# Local development data
.dev-data/
deploy/data/
# 仓库维护的工具规则(其他本地技能不入库)
# Repository-maintained tool rules (other local skills stay untracked)
.agents/skills/*
!.agents/skills/v3-*/
+2 -2
View File
@@ -2,7 +2,7 @@
## 语言与沟通
- 回复使用简体中文;注释、文档字符串、日志和错误信息遵循项目既有语言约定,无约定时优先使用简体中文。
- 回复使用简体中文;代码注释、文档字符串及配置/脚本中的注释统一使用英文。日志和错误信息遵循项目既有语言约定,无约定时优先使用简体中文。
- 命名使用清晰英文;Git commit 标题与正文使用英文并遵循项目约定。
- Git commit 不得添加 Claude、Claude Code、AI 助手等作为 Contributor、Co-authored-by 或其他署名;不得添加 AI 生成声明或署名 trailer,除非用户明确要求。
- 先给结论,按需解释,明确未验证的信息。
@@ -110,7 +110,7 @@ RawMark 是单用户 Markdown 网页笔记编辑器。文件是真源;Go 标
```bash
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 typecheck
pnpm -C web test
+3 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
# ---- 前端 ----
# ---- Frontend ----
FROM node:24-alpine AS web
WORKDIR /src/web
RUN corepack enable && corepack prepare [email protected] --activate
@@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm build
# ---- 服务端(前端产物嵌入二进制) ----
# ---- Backend (frontend assets embedded in the binary) ----
FROM golang:1-alpine AS server
WORKDIR /src
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
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
COPY --from=server /out/rawmark /rawmark
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_VAULT 笔记仓库目录,默认 $RAWMARK_DATA/vault;可指向已有的 Typora 笔记目录
// RAWMARK_ADDR 监听地址,默认 :8080
// RAWMARK_DATA Data directory; defaults to /data (rawmark.db, rebuildable index.db, and the default vault).
// RAWMARK_VAULT Note vault; defaults to $RAWMARK_DATA/vault and may point to an existing Typora directory.
// RAWMARK_ADDR Listen address; defaults to :8080.
package main
import (
@@ -65,7 +65,7 @@ func run(addr string) error {
if err != nil {
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")
if err = os.MkdirAll(tempDir, 0700); err != nil {
return err
@@ -208,7 +208,7 @@ func serve(parent context.Context, addr, dataDir, vaultDir, version string, font
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 {
if errors.Is(err, fs.ErrPermission) {
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 {
_, port, err := net.SplitHostPort(addr)
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 {
~^/api/v1/resources/[^/]+(?<rawmark_resource_suffix>/.*)$ /api/v1/resources/[redacted]$rawmark_resource_suffix;
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 '
'"$http_user_agent"';
# raw.134333.xyz -> RawMark 容器(127.0.0.1:21000)
# raw.134333.xyz -> RawMark container (127.0.0.1:21000)
server {
listen 80;
@@ -38,7 +38,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
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/ {
proxy_pass http://127.0.0.1:21000;
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
import (
@@ -37,9 +37,9 @@ type Server struct {
store *store.Store
hub *events.Hub
index *search.Index
// 串行化设置的"读取-合并-写入"
// Serialize settings read/merge/write operations.
settingsMu sync.Mutex
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
// ThemesDir points to the data directory's themes folder; an empty value enables only built-in themes.
DataDir string
ThemesDir 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()}
}
// access 为接口的访问要求
// access defines an endpoint's access requirement.
type access int
const (
public access = iota
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
// Login session or API token (read-only tokens may use GET and read-only exports).
authed
// 只允许登录会话:管理 API 令牌,避免令牌给自己扩权
// Login sessions only: prevent API tokens from expanding their own permissions through token management.
sessionOnly
)
@@ -79,7 +79,7 @@ type route struct {
handler http.HandlerFunc
}
// routes 为全部接口;openapi.json 须与之一致(见 TestOpenAPICoversRoutes)
// routes lists all endpoints; openapi.json must match it (see TestOpenAPICoversRoutes).
func (s *Server) routes() []route {
return []route{
{"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))))
}
// ---- 中间件 ----
// ---- Middleware ----
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -185,8 +185,8 @@ func securityHeaders(next http.Handler) http.Handler {
})
}
// sameOrigin 保持写请求的同源边界;Bearer 鉴权不依赖浏览器 Cookie。
// 没有 Origin 头的请求来自非浏览器客户端,放行
// sameOrigin preserves the same-origin boundary for writes; Bearer authentication does not rely on browser Cookies.
// Requests without Origin come from non-browser clients and are allowed.
func sameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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) {
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})
}
// ---- 文件 ----
// ---- Files ----
func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
entries, err := s.vault.Tree()
@@ -331,7 +331,7 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
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) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
if err != nil {
@@ -371,10 +371,10 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
return
}
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"})
} else {
// 图标提交在文件移动之后;不能仅依赖文件监听器较早的通知。
// Icons commit after the file move; the watcher's earlier notification alone is insufficient.
s.hub.Broadcast(events.Event{Type: "tree"})
}
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})
}
// 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) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
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})
}
// replaceImage 仅替换已存在的图片;路径与引用保持不变。
// replaceImage replaces only an existing image, preserving its path and references.
func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
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})
}
// 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) {
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)
}
// ---- 回收站 ----
// ---- Recycle bin ----
func (s *Server) trashList(w http.ResponseWriter, r *http.Request) {
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})
}
// ---- 响应工具 ----
// ---- Response helpers ----
// clientIP 返回限速用的来源地址(未经反向代理头部,避免伪造)
// clientIP returns the source address for rate limiting, ignoring proxy headers to prevent spoofing.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
+5 -5
View File
@@ -33,7 +33,7 @@ type fixture struct {
root string
store *store.Store
hub *events.Hub
// 已登录的客户端
// Authenticated client.
admin *http.Client
}
@@ -88,7 +88,7 @@ func TestEventsRejectRevokedCredentials(t *testing.T) {
t.Fatal(err)
}
defer conn.CloseNow()
// 首条事件确认订阅已经建立,避免把握手和广播的时序当作鉴权结果。
// The first event confirms subscription before testing authorization, avoiding handshake/broadcast timing races.
f.hub.Broadcast(events.Event{Type: "tree"})
if _, _, err := conn.Read(ctx); err != nil {
t.Fatal(err)
@@ -238,7 +238,7 @@ func decode[T any](t *testing.T, res *http.Response) T {
return envelope.Data
}
// 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败
// Documentation must match actual endpoints: missing new routes and obsolete documented routes both fail.
func TestOpenAPICoversRoutes(t *testing.T) {
var spec struct {
Paths map[string]map[string]json.RawMessage
@@ -271,7 +271,7 @@ func TestOpenAPICoversRoutes(t *testing.T) {
func TestAPIResponsesAreNotCached(t *testing.T) {
f := newFixture(t)
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"} {
res := f.do(f.admin, "GET", path, "", nil)
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 {
t.Fatalf("重复初始化必须拒绝: %d", status)
}
// 新建 Manager 模拟重启,不能重新开放密码设置。
// A new Manager simulates a restart and must not reopen password setup.
reopened := auth.New(st)
if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) {
t.Fatalf("重启后不能覆盖密码: %v", err)
+5 -5
View File
@@ -9,11 +9,11 @@ import (
"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) {
s.eventWG.Add(1)
defer s.eventWG.Done()
// 先订阅再完成握手,避免连接建立后首条变更落在订阅空窗中。
// Subscribe before completing the handshake so the first change cannot fall into a subscription gap.
msgs, unsubscribe := s.hub.Subscribe()
defer unsubscribe()
conn, err := websocket.Accept(w, r, nil)
@@ -36,9 +36,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
}
return true
}
// 客户端不发消息;CloseRead 负责处理控制帧并在连接断开时取消 ctx
// Clients send no messages; CloseRead handles control frames and cancels ctx on disconnect.
ctx := conn.CloseRead(r.Context())
// 定期 ping,避免反向代理因空闲断开连接
// Ping periodically to prevent idle disconnects by reverse proxies.
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
@@ -47,7 +47,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
return
case msg, ok := <-msgs:
if !ok {
// 客户端消费过慢被移出广播列表,断开后由客户端重连并全量同步
// Disconnect slow subscribers removed from the hub; clients reconnect and perform a full sync.
conn.Close(websocket.StatusTryAgainLater, "too slow")
return
}
+1 -1
View File
@@ -76,7 +76,7 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) {
t.Fatal(err)
}
}
// 与真实进程一致,导出暂存文件位于应用数据目录内。
// As in the real process, export temporary files live inside the application data directory.
t.Setenv("TMPDIR", filepath.Join(data, "temp"))
res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil)
defer res.Body.Close()
+1 -1
View File
@@ -235,7 +235,7 @@ func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
return
}
defer root.Close()
// 先撤下资源;设置写失败时恢复,避免留下指向已删除字体的设置。
// Remove the resource first and restore it if settings fail to save, avoiding references to a deleted font.
removed := ".remove-" + id
if err := root.Rename(id, removed); err != nil {
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)" {
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 {
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() {
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) {
return false
}
@@ -64,7 +64,7 @@ func checkImageURL(u *url.URL) error {
var remoteImageClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
// 不使用环境代理,确保每个实际连接都经过目标地址校验。
// Ignore environment proxies so every actual connection passes destination-address validation.
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
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) {
data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1))
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)))
return
}
// url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。
// url.Error includes the full source URL, potentially with temporary signatures; log only its underlying cause.
var urlError *url.Error
if errors.As(err, &urlError) {
err = urlError.Err
@@ -208,7 +208,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
}
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)
if err != nil {
vaultError(w, r, err)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
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))
if err := r.ParseMultipartForm(8 << 20); err != nil {
var limit *http.MaxBytesError
+1 -1
View File
@@ -95,7 +95,7 @@ func TestImportImageDownloadAndRedirects(t *testing.T) {
transport := original.Transport.(*http.Transport).Clone()
dial := transport.DialContext
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" {
return (&net.Dialer{}).DialContext(ctx, network, source.Listener.Addr().String())
}
+1 -1
View File
@@ -1,4 +1,4 @@
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() }
+2 -2
View File
@@ -29,7 +29,7 @@ type maintenanceStatus struct {
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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
}
// 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) {
var body struct {
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)
}
id := decode[struct{ ID string }](t, res).ID
// 跨多个分块验证偏移,不引入测试开关。
// Verify offsets across multiple chunks without introducing test switches.
offset := 0
for offset < len(data) {
end := min(offset+137, len(data))
@@ -192,7 +192,7 @@ func TestFullBackupRestoresFilesDatabaseAndInvalidatesSessions(t *testing.T) {
root := t.TempDir()
vault := filepath.Join(root, "vault")
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 {
t.Fatal(err)
}
@@ -273,7 +273,7 @@ func TestSettingsPackageDatabaseFailureRollsBack(t *testing.T) {
f.store.SetSetting(settingsKey, `{"fontSize":24}`)
data := exportedPackage(t, f, "settings")
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"))
if err != nil {
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 {
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) {
return err
}
@@ -368,7 +368,7 @@ type cleanupResult struct {
func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
result := cleanupResult{Failed: []string{}}
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()
if err != nil {
return err
@@ -381,7 +381,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
for _, entry := range entries {
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")
entries, err = os.ReadDir(uploads)
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)
}
// 仅用于服务启动前读取恢复结果,不以空响应伪装已恢复。
// Read restore results only before service startup; never disguise an empty response as a completed restore.
func (s *Server) SetRestoreFailure(err error) {
s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock()
+2 -2
View File
@@ -38,7 +38,7 @@ func TestOpenAPIResponseContracts(t *testing.T) {
}
return op
}
// 下载、原文、资源代理与 OpenAPI 本身不使用业务 JSON 外壳。
// Downloads, raw files, resource proxies, and OpenAPI itself do not use the business JSON envelope.
raw := map[string]bool{
"get /openapi.json": true, "get /files/{path}": true, "get /raw/{path}": 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("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"))
// 仅验证提交契约;合成实例不触发真实进程重启或恢复执行。
// Verify only the submission contract; the synthetic instance must not trigger a real process restart or restore.
f.server.Restart = func() {}
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 {
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
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 {
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"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"
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)
}
// 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) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes))
if err != nil {
@@ -86,7 +86,7 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
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) {
var body struct{ Current, Next string }
if !readJSON(w, r, &body) {
+8 -8
View File
@@ -12,19 +12,19 @@ import (
"time"
)
// 应用页面的 CSP:CodeMirror 通过 <style> 注入样式,需要 'unsafe-inline';
// 笔记中可引用外部图片,img-src 放开 http/https
// Application CSP: CodeMirror injects <style> elements and requires 'unsafe-inline';
// notes may reference external images, so img-src allows http/https.
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:; " +
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
type staticFile struct {
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 {
fsys fs.FS
started time.Time
@@ -42,14 +42,14 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
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" {
writeError(w, 404, message(w, "page_not_found_e75737"))
return
}
f, err := h.load(name)
if err != nil {
// 前端路由:未知路径一律返回入口页面
// Frontend routing: unknown paths return the entry page.
name = "index.html"
if f, err = h.load(name); err != nil {
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)
}
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")
} else if ct := mime.TypeByExtension(path.Ext(name)); 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)
}
// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。
// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。
// 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) {
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
if dataDir == "" {
@@ -80,7 +80,7 @@ func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, e
}
if walkErr != nil {
if path != root && errors.Is(walkErr, fs.ErrNotExist) {
return nil // 文件可能在扫描期间被移动或删除。
return nil // Files may move or disappear during the scan.
}
return walkErr
}
+1 -1
View File
@@ -19,7 +19,7 @@
--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:hover,
.rm-segmented button.on {
+1 -1
View File
@@ -40,7 +40,7 @@ func TestThemeBoundaries(t *testing.T) {
if !strings.Contains(theme["css"], "#faf6ed") {
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 {
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})
}
// 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) {
var body struct{ Name, Scope string }
if !readJSON(w, r, &body) {
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"time"
)
// JSON/JavaScript 共同精确表示的非负整数范围;不乘天数,避免期限溢出。
// Nonnegative integers exactly representable in both JSON and JavaScript; avoid multiplying by days, which could overflow.
const maxRetentionDays = 1<<53 - 1
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 {
s.activity.RLock()
defer s.activity.RUnlock()
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。
// Serialize with settings writes so cleanup cannot start with stale retention after "never delete" is saved.
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
settings, err := s.readSettings()
@@ -64,7 +64,7 @@ func (s *Server) cleanupTrash(now time.Time) error {
return nil
}
// RunTrashCleanup 启动时执行一次,其后每小时执行;不依赖浏览器在线。
// RunTrashCleanup runs at startup and hourly thereafter, independently of browser connections.
func (s *Server) RunTrashCleanup(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
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
import (
@@ -25,13 +25,13 @@ const (
SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8
// RFC 9106 推荐的第二组参数(内存受限环境)
// The second recommended parameter set in RFC 9106 (memory-constrained environments).
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
// 同一来源在窗口内累计失败 maxFailures 次即封禁 lockout
// Block a source for lockout after maxFailures failures within the window.
maxFailures = 30
failureWindow = 15 * time.Minute
lockout = 15 * time.Minute
@@ -49,7 +49,7 @@ type Manager struct {
store *store.Store
mu sync.Mutex
failures map[string][]time.Time
blocked map[string]time.Time // 来源 -> 解封时间
blocked map[string]time.Time // Source -> unblock time.
now func() time.Time
}
@@ -63,7 +63,7 @@ func (m *Manager) IsSetUp() (bool, 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()
if err != nil {
return err
@@ -85,12 +85,12 @@ func (m *Manager) Setup(password string) error {
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) {
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) {
if !m.allow(ip) {
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)) }
// 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 {
if !m.allow(ip) {
return ErrRateLimited
@@ -164,9 +164,9 @@ func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
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) {
if scope != "read" && scope != "write" {
return "", store.APIToken{}, ErrBadScope
@@ -179,7 +179,7 @@ func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, er
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) {
if !strings.HasPrefix(plain, apiTokenPrefix) {
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())
}
// ---- 限速:同一来源在时间窗口内失败过多则封禁一段时间 ----
// ---- Rate limiting: temporarily block sources with too many failures within the window ----
func (m *Manager) allow(ip string) bool {
m.mu.Lock()
@@ -226,7 +226,7 @@ func (m *Manager) clearFailures(ip string) {
m.mu.Unlock()
}
// ---- 密码与令牌 ----
// ---- Passwords and tokens ----
func hashPassword(password string) string {
salt := make([]byte, 16)
@@ -272,7 +272,7 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:])
}
// randomCode 生成形如 ABCD-EFGH 的 API 令牌标识,去掉易混淆字符
// randomCode generates an API-token identifier such as ABCD-EFGH, excluding ambiguous characters.
func randomCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8)
+2 -2
View File
@@ -25,7 +25,7 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
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) {
m, clock := newManager(t)
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) {
m, clock := newManager(t)
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
import (
@@ -23,7 +23,7 @@ const MaxArchiveBytes int64 = 2 << 30
const MaxExpandedBytes int64 = 8 << 30
const MaxEntries = 100000
// ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。
// ErrInvalidArchive denotes a backup-contract violation, excluding disk or filesystem failures.
var ErrInvalidArchive = i18n.New("invalid_backup_contents_ecff07")
type Entry struct {
@@ -59,7 +59,7 @@ func validEntry(kind, name string) bool {
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) {
if kind != "full" && kind != "settings" {
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()
}
// 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) {
var manifest Manifest
z, err := zip.OpenReader(archive)
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"sync"
)
// LogWriter 保留三份各不超过 10 MiB 的应用日志,权限 0600。
// LogWriter retains three application log files of at most 10 MiB each, with mode 0600.
type LogWriter struct {
mu sync.Mutex
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() }
// Snapshot 读取当前日志的稳定副本,避免导出与日志轮转竞争。
// Snapshot reads stable copies of current logs to avoid races with rotation during export.
func (l *LogWriter) Snapshot(dest string) error {
l.mu.Lock()
defer l.mu.Unlock()
+9 -9
View File
@@ -17,8 +17,8 @@ import (
sqlite3 "modernc.org/sqlite/lib"
)
// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。
// prepared 尚未改动活动数据,applying 表示上次中断,必须先回退。
// RestoreRecord must run after HTTP, file watching, indexing, and databases have shut down.
// prepared means active data is unchanged; applying indicates an interrupted restore that must first roll back.
type RestoreRecord struct {
State string `json:"state"`
Kind string `json:"kind,omitempty"`
@@ -70,7 +70,7 @@ func Pending(data string) (bool, 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)
if err != nil {
return err
@@ -122,7 +122,7 @@ func CheckDatabase(file string) error {
return nil
}
// VaultExcluded 支持外置与嵌套仓库,避免将应用文件重复备份为笔记。
// VaultExcluded supports external and nested vaults, avoiding duplicate application files in note backups.
func VaultExcluded(data, vault, rel string) bool {
for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
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())
}
// CopyTree 不覆盖任何已有目标,也不允许跟随链接。
// CopyTree never overwrites an existing destination or follows symlinks.
func CopyTree(src, dest string) error {
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil {
@@ -205,7 +205,7 @@ func clearVault(data, root, dir string) error {
if VaultExcluded(data, root, rel) {
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 err = clearVault(data, root, full); err != nil {
return err
@@ -258,7 +258,7 @@ func apply(data, vault, stage string) error {
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) {
raw, err := os.ReadFile(recordPath(data))
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" {
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)) {
return i18n.New("invalid_restore_record_path_611881")
}
@@ -315,7 +315,7 @@ func Recover(data, vault string) (err error) {
return nil
}
// FinishRecovery 在新实例的索引、监听与端口初始化成功后提交恢复。
// FinishRecovery commits the restore after the new instance's index, watcher, and listener initialize successfully.
func FinishRecovery(data string) error {
raw, err := os.ReadFile(recordPath(data))
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
import (
@@ -7,13 +7,13 @@ import (
"sync"
)
// Event 为推送给客户端的消息
// Event is a message pushed to clients.
//
// tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树
// changed 笔记内容变化,Rev 为新版本
// deleted 笔记被删除
// moved 文件或目录被移动(From -> Path),目录事件包含其子项的路径映射
// settings 应用设置变化,客户端重新读取
// tree Directory structure changed (create/delete/move); clients reload the tree.
// changed Note contents changed; Rev contains the new revision.
// deleted A note was deleted.
// moved A file or folder moved (From -> Path); folder events include child path mappings.
// settings Application settings changed; clients reload them.
type Event struct {
Type string `json:"type"`
Path string `json:"path,omitempty"`
@@ -29,7 +29,7 @@ type Hub 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()) {
ch := make(chan []byte, 64)
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() {
h.mu.Lock()
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
}
// Rebuild 与后台同步互斥,失败时事务和已知快照均保持原状。
// Rebuild excludes background synchronization; failures leave both the transaction and known snapshot unchanged.
func (x *Index) Rebuild(ctx context.Context) error {
x.work.Lock()
defer x.work.Unlock()
+1 -1
View File
@@ -41,7 +41,7 @@ func TestRebuildSupersedesQueuedOldSnapshot(t *testing.T) {
f := newFixture(t)
f.write("old.md", "old")
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", "中文新笔记")
if err := f.idx.Rebuild(context.Background()); err != nil {
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
import (
@@ -20,7 +20,7 @@ import (
"rawmark/internal/vault"
)
// 表结构或派生元数据的解析规则变化时递增,旧索引会被丢弃重建。
// Increment when schema or derived-metadata parsing changes; old indexes are discarded and rebuilt.
const schemaVersion = 3
const schema = `
@@ -30,7 +30,7 @@ CREATE TABLE docs (
rev 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');
`
@@ -42,7 +42,7 @@ const (
type Result struct {
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"`
Snippet string `json:"snippet"`
}
@@ -60,12 +60,12 @@ type Index struct {
v *vault.Vault
mu sync.Mutex
pending map[string]string // 待同步的最新快照(路径 -> rev)
pending map[string]string // Latest pending snapshot (path -> rev).
wake chan struct{}
pendingVersion uint64
work sync.Mutex // 串行化同步、重建和数据库维护
known map[string]doc // 已索引的笔记,仅由同步协程访问
work sync.Mutex // Serialize synchronization, rebuilding, and database maintenance.
known map[string]doc // Indexed notes, accessed only by the synchronization goroutine.
}
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() }
// 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) {
x.mu.Lock()
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) {
for {
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 {
x.work.Lock()
defer x.work.Unlock()
@@ -175,7 +175,7 @@ func (x *Index) syncLocked(files map[string]string) error {
}
content, rev, err := x.v.Read(p)
if errors.Is(err, vault.ErrNotFound) {
continue // 快照之后被删除,下一次快照会处理
continue // Deleted after the snapshot; the next snapshot will handle it.
}
if err != nil {
return err
@@ -210,8 +210,8 @@ func remove(tx *sql.Tx, id int64) error {
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) {
terms := strings.Fields(query)
if len(terms) == 0 {
@@ -263,7 +263,7 @@ func escapeLike(s string) string {
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) {
folded := make([][]rune, len(terms))
for i, t := range terms {
@@ -298,7 +298,7 @@ func snippet(body string, terms []string) (int, string) {
return 0, ""
}
// fold 逐字符转小写,保持字符数不变,便于在原文中按位置截取
// fold lowercases rune by rune, preserving character count for slicing the original text by position.
func fold(rs []rune) []rune {
out := make([]rune, len(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() {
f.t.Helper()
entries, err := f.v.Tree()
@@ -92,11 +92,11 @@ func TestSearchTerms(t *testing.T) {
f.sync()
cases := map[string]string{
"装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS
"检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE
"codemirror": "a.md", // 不区分大小写
"装饰器 检索": "目录/c.md", // 多个词同时满足
"目录": "目录/c.md", // 命中路径
"装饰器": "a.md,目录/c.md", // Use FTS for at least three characters.
"检索": "b.md,目录/c.md", // Use LIKE for fewer than three characters.
"codemirror": "a.md", // Case-insensitive.
"装饰器 检索": "目录/c.md", // Match every term.
"目录": "目录/c.md", // Path match.
"不存在的词": "",
"100%": "",
`"引号"`: "",
@@ -169,7 +169,7 @@ func TestSyncTracksChanges(t *testing.T) {
}
}
// 重启后沿用已有索引,只处理变化
// Reuse the existing index after restart and process only changes.
f.reopen()
if len(f.idx.known) != 2 {
t.Fatalf("重启后应载入已索引笔记: %v", f.idx.known)
@@ -210,7 +210,7 @@ func TestRebuildsLinkedImageMetadataFromOldIndex(t *testing.T) {
f := newFixture(t)
f.write("a.md", "[![预览](assets/used.png)](https://example.com)")
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 {
t.Fatal(err)
}
+4 -4
View File
@@ -9,7 +9,7 @@ import (
"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 {
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, dest)
return err
@@ -34,7 +34,7 @@ func (s *Store) Preferences() (map[string]json.RawMessage, error) {
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 {
tx, err := s.db.Begin()
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 }
// CheckBackup 确认数据库可读取且包含当前版本所需表;不会初始化或修复传入文件。
// CheckBackup verifies readability and required tables without initializing or repairing the supplied database.
func (s *Store) CheckBackup() error {
var result string
if err := s.db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil {
@@ -81,7 +81,7 @@ func (s *Store) CheckBackup() error {
return nil
}
// OpenExisting 只打开已有库;导入校验不得创建表或更改日志模式。
// OpenExisting opens only existing databases; import validation must not create tables or change journal modes.
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())
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
import (
@@ -55,7 +55,7 @@ func Open(path string) (*Store, error) {
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) {
err = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
@@ -69,7 +69,7 @@ func (s *Store) SetSetting(key, value string) error {
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) {
res, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`, key, value)
if err != nil {
@@ -85,7 +85,7 @@ func (s *Store) CreateSession(tokenHash string, expires time.Time) error {
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) {
var expires int64
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
}
// 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 {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash)
return err
@@ -115,9 +115,9 @@ func (s *Store) DeleteExpiredSessions() error {
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 {
ID string `json:"id"`
Name string `json:"name"`
@@ -150,7 +150,7 @@ func (s *Store) APITokens() ([]APIToken, error) {
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) {
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) {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"time"
)
// ResourceTicket 只保存资源凭据哈希,所属登录会话是授权的最终依据。
// ResourceTicket stores only a resource credential hash; the owning login session remains the final authority.
type ResourceTicket struct {
Hash string
SessionHash string
@@ -25,7 +25,7 @@ func (s *Store) Ticket(hash string, consume bool, now time.Time) (ResourceTicket
var t ResourceTicket
var expires int64
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 {
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:"-"`
}
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
// CleanupImages rescans references under the write lock; the derived index never authorizes deletion.
func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
v.mu.Lock()
+4 -4
View File
@@ -14,8 +14,8 @@ import (
"strings"
)
// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。
// Export builds an export in a temporary file; the caller must close and remove it after success.
// 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) {
if len(paths) == 0 {
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
}
}
// 仅收集已选笔记的附件,不追踪链接到的其他笔记。
// Collect attachments only for selected notes, without following links to other notes.
notes := make([]string, 0)
for p, dir := range files {
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
}
// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。
// Attachments may use ../ to reference other folders, but cannot expose hidden paths or escape scope through symlinks.
func (v *Vault) exportPath(rel string) error {
full, err := v.abs(rel)
if err != nil {
+3 -3
View File
@@ -17,7 +17,7 @@ type MissingAsset struct {
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) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -100,7 +100,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
return e
}
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, "://") {
continue
}
@@ -117,7 +117,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
return nil, err
}
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"))
if e != nil {
return nil, e
+3 -3
View File
@@ -13,7 +13,7 @@ import (
"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 {
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
return i18n.New("w_choose_a_file_with_8410e9", ErrInvalidPath)
@@ -52,7 +52,7 @@ type linkChange struct {
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) {
entries, err := v.Tree()
if err != nil {
@@ -103,7 +103,7 @@ func movedPath(p, from, to string, directory bool) string {
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) {
var out []byte
last := 0
+7 -7
View File
@@ -28,7 +28,7 @@ const (
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 {
Name string
Reader io.ReaderAt
@@ -43,7 +43,7 @@ type ImportResult struct {
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) {
result = ImportResult{Paths: []string{}, Warnings: []string{}}
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{}{}
var total int64
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 {
if err := ctx.Err(); err != nil {
return err
@@ -140,7 +140,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
for _, f := range z.File {
if f.Name == ".rawmark-export/missing-assets.json" {
continue
} // 本程序笔记导出的缺失附件报告,不作为笔记导入。
} // Do not import RawMark's missing-attachment export report as a note.
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
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
}
}
// 所有笔记改链完成后再清理已迁移的原图,避免后处理的笔记读取不到共享来源。
// Clean migrated source images only after rewriting all notes, so later notes can still read shared sources.
if len(migrated) > 0 {
used := map[string]bool{}
for _, note := range notes {
@@ -232,7 +232,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
}
}
result.Notes = len(notes)
// 附件数量为最终保留的文件,包含目录共享图片与其他原始附件。
// The attachment count covers final retained files, including folder-shared images and other original attachments.
count := 0
err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
if err != nil {
@@ -296,7 +296,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err
}
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))
} else {
var published []string
+7 -7
View File
@@ -23,7 +23,7 @@ type importLink struct {
html bool
}
// 链接中的空白允许一次换行,但不能跨过段落。
// Whitespace in links may include one newline but cannot cross paragraphs.
func linkSpace(text []byte, pos int) int {
newline := false
for pos < len(text) {
@@ -42,7 +42,7 @@ func linkSpace(text []byte, pos int) int {
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) {
start = linkSpace(text, pos)
if start == len(text) {
@@ -105,7 +105,7 @@ func linkTitleEnd(text []byte, pos int) int {
return -1
}
// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。
// importLinkSpans serves import, export, and image renaming, returning original byte positions without reserializing text.
func importLinkSpans(content []byte) []importLink {
var text []byte
var source []int
@@ -123,7 +123,7 @@ func importLinkSpans(content []byte) []importLink {
lastEnd = offset + len(line)
return line
})
// 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。
// Inline code can span lines. Mask its markers while preserving bracket structure around link labels.
masked := bytes.Repeat([]byte(" "), len(text))
pos := 0
for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) {
@@ -171,7 +171,7 @@ func importLinkSpans(content []byte) []importLink {
}
add(start, end, false)
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++ {
if text[i] != '\n' && text[i] != '\r' {
text[i] = ' '
@@ -214,7 +214,7 @@ func importLinkSpans(content []byte) []importLink {
add(start, end, false)
i = close
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 {
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")
dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data)
if err != nil {
+16 -16
View File
@@ -6,8 +6,8 @@ import (
"strings"
)
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。
// RewriteAssetLinks updates links from the old image directory (oldBase.assets/) after renaming a note.
// Handle inline links/images `](...)`, reference definitions `[x]: ...`, and HTML `src="..."`, skipping code and math.
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(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 {
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
paragraph := false
lineOffset := 0
for i, line := range lines {
offset := lineOffset
lineOffset += len(line)
// 分类时展开制表符,替换仍作用于原始字节,不重新序列化 Markdown。
// Expand tabs for classification, but apply replacements to original bytes without reserializing Markdown.
body := bytes.TrimRight(expandLinkTabs(line), "\r\n")
width := len(body)
matched := 0
@@ -68,7 +68,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
body = body[1:]
}
} else if indent == len(body) {
body = nil // 空行不结束列表容器。
body = nil // Blank lines do not close list containers.
} else if indent >= container.indent {
body = body[container.indent:]
} else {
@@ -80,14 +80,14 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
rest := bytes.TrimLeft(body, " ")
if marker := listMarker.Find(rest); len(body)-len(rest) <= 3 && marker != nil {
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 {
paragraph = false
}
}
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 {
trimmed := bytes.TrimLeft(body, " ")
@@ -96,7 +96,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
}
continue
}
// 新的引用或列表可以嵌套;列表的后续行按其内容起始列剥离缩进。
// New quotes or lists may nest; strip continuation indentation using the list's content-start column.
for {
indent := len(body) - len(bytes.TrimLeft(body, " "))
if indent > 3 || indent == len(body) {
@@ -110,10 +110,10 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
body = body[1:]
}
} else if paragraphBoundary.Match(rest) {
break // 分隔线优先于列表标记。
break // Thematic breaks take precedence over list markers.
} else if marker := listMarker.Find(rest); marker != nil {
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")))) {
break
}
@@ -134,7 +134,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
paragraph = false
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 {
continue
}
@@ -144,7 +144,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
continue
}
paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed)
// 将剥离容器时的展开列数映射回原始字节,保留制表符和正文位置。
// Map expanded container columns back to original bytes, preserving tabs and text positions.
start, column := 0, 0
for column < width-len(body) {
if line[start] == '\t' {
@@ -183,7 +183,7 @@ func expandLinkTabs(line []byte) []byte {
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 {
indent := len(line) - len(bytes.TrimLeft(line, " "))
if indent > 3 {
@@ -203,7 +203,7 @@ func fenceOpen(line []byte) []byte {
return rest[:n]
}
// transformOutsideCode 只在行内代码 `…` 之外的片段上做转换。
// transformOutsideCode transforms only fragments outside inline code `...`.
func transformOutsideCode(line []byte, transform func(text []byte, offset int) []byte) []byte {
var out []byte
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}_/-]*`)
// 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) {
raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1"))
u, err := url.Parse(raw)
@@ -46,7 +46,7 @@ func LocalTarget(note, raw string) (string, *url.URL, bool) {
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 {
m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
addWarning := func(key string, args ...any) {
@@ -138,7 +138,7 @@ func Analyze(note string, content []byte) Metadata {
return ast.WalkSkipChildren, nil
case *ast.Link:
addRef(node.Destination.Str(source), node.Pos())
// 链接文字中可以嵌套图片,仍须记录其资源引用。
// Link labels may contain nested images whose resource references must still be recorded.
case *ast.Image:
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
@@ -156,7 +156,7 @@ func Analyze(note string, content []byte) Metadata {
}
return ast.WalkContinue, nil
})
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
// Historically imported notes may reference HTML resources; cleanup must include them.
excluded := nonProseRanges(root)
for _, span := range importLinkSpans(content) {
if !span.html {
+3 -3
View File
@@ -12,8 +12,8 @@ import (
"github.com/yuin/goldmark/v2/util"
)
// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。
// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。
// Relationship extraction only: math and code are not body text, so #1 and example links must not become knowledge relationships.
// Delimiter rules match frontend Typora extensions; this parser does not render Markdown.
type metadataMath struct{}
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 non-body byte ranges so lossless link rewriting can skip example syntax inside math.
func nonProseRanges(root ast.Node) [][2]int {
var ranges [][2]int
_ = 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
}
// 调用方持有写锁,并在后续笔记移动失败时撤回新副本。
// 旧的独立附件仍由 Move 连同目录移动;共享图片只在其他笔记不再引用时移走原件。
// The caller holds the write lock and removes new copies if the subsequent note move fails.
// 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) {
content, _, err := v.Read(from)
if err != nil {
@@ -68,7 +68,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ
}
info, e := os.Lstat(source)
if os.IsNotExist(e) {
continue // 已缺失的图片保留链接,由缺失检查报告。
continue // Retain links to already-missing images for the missing-resource report.
}
if e != nil {
return transfers, targets, e
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"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 {
dir := filepath.Join(v.trash, metadata.ID)
entries, err := os.ReadDir(dir)
@@ -29,7 +29,7 @@ func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
return err
}
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)
return errors.Join(err, atomicWrite(metaPath, data))
}
@@ -47,8 +47,8 @@ type TrashPurgeResult struct {
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) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -63,7 +63,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
for _, dir := range dirs {
id := dir.Name()
if !trashIDPattern.MatchString(id) {
continue // 不删除非本程序管理的文件。
continue // Do not delete files not managed by this application.
}
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) {
+1 -1
View File
@@ -143,7 +143,7 @@ func TestTrashRetentionAndInvalidMetadata(t *testing.T) {
t.Fatalf("不应删除未过期/无效条目: %v", err)
}
}
// 极大天数不应溢出成过期期限。
// Very large day counts must not overflow into an expired retention period.
result, err = v.PurgeTrash(1<<53-1, now)
if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 {
t.Fatalf("极大期限: %+v, %v", result, err)
+3 -3
View File
@@ -12,7 +12,7 @@ import (
"unicode/utf8"
)
// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。
// TreeIcon is a file-tree display attribute and does not affect Markdown contents or revisions.
type TreeIcon struct {
Kind string `json:"kind"`
Value string `json:"value"`
@@ -105,7 +105,7 @@ func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error {
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 {
taken := map[string]TreeIcon{}
for p, icon := range icons {
@@ -117,7 +117,7 @@ func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[s
return taken
}
// 回收站保留属性,但普通回收站 API 不暴露内部元数据。
// The recycle bin retains attributes, but ordinary recycle-bin APIs do not expose internal metadata.
type trashMetadata struct {
TrashItem
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) {
t.Fatalf("恢复默认失败: %v", got)
}
// 原有无图标的回收站条目也可恢复。
// Legacy recycle-bin entries without icons remain restorable.
item, err = v.Delete("replacement")
if err != nil {
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
import (
@@ -30,7 +30,7 @@ var (
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 }
func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() }
@@ -43,9 +43,9 @@ const (
)
type Vault struct {
root string // 已解析符号链接的绝对路径
root string // Absolute path with symlinks resolved.
trash string
// 串行化所有写操作,保证"检查版本 + 写入"、移动等复合操作的原子性
// Serialize writes to make compound operations such as revision-check/write and moves atomic.
mu sync.Mutex
}
@@ -53,7 +53,7 @@ type Entry struct {
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // 毫秒
ModTime int64 `json:"mtime"` // Milliseconds.
Info fs.FileInfo `json:"-"`
Icon *TreeIcon `json:"icon,omitempty"`
}
@@ -62,8 +62,8 @@ type TrashItem struct {
ID string `json:"id"`
Path string `json:"path"`
Dir bool `json:"dir"`
DeletedAt int64 `json:"deletedAt"` // 毫秒
// 随笔记一起删除的 .assets 目录名(相对回收站条目目录)
DeletedAt int64 `json:"deletedAt"` // Milliseconds.
// .assets directory deleted with the note, relative to the recycle-bin entry directory.
Assets string `json:"assets,omitempty"`
}
@@ -88,14 +88,14 @@ func Open(root string) (*Vault, error) {
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 {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:8])
}
// CleanPath 校验客户端传入的相对路径:不允许绝对路径、空段、"."/".." 段以及以 "." 开头的隐藏段
// (隐藏段包括 .rawmark 元数据目录与 .git 等,一律不经 API 暴露)
// CleanPath validates client-relative paths: reject absolute paths, empty segments, "."/"..", and hidden segments starting with ".".
// Hidden segments include .rawmark metadata and .git; none are exposed through the API.
func CleanPath(p string) (string, error) {
if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") {
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) }
// 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 (v *Vault) abs(rel string) (string, error) {
@@ -125,7 +125,7 @@ func (v *Vault) abs(rel string) (string, error) {
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 {
for cur := p; ; {
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) {
if !IsNote(rel) {
@@ -166,7 +166,7 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
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) {
p, err := v.abs(rel)
if err != nil {
@@ -179,7 +179,7 @@ func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
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 {
IfMatch string
IfNoneMatch bool
@@ -220,7 +220,7 @@ func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, err
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 {
dir := filepath.Dir(p)
f, err := os.CreateTemp(dir, tmpPrefix+"*")
@@ -228,7 +228,7 @@ func atomicWrite(p string, data []byte) error {
return err
}
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 {
f.Close()
return err
@@ -266,7 +266,7 @@ func (v *Vault) Mkdir(rel string) error {
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) {
p, err := v.abs(rel)
if err != nil {
@@ -290,9 +290,9 @@ func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
return f, info, nil
}
// ---- 列表 ----
// ---- Listing ----
// IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。
// IsImage recognizes extensions supported by image management; it does not validate file contents.
func IsImage(p string) bool {
switch strings.ToLower(path.Ext(p)) {
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) {
entries, err := v.TreeAt("")
if err != nil {
@@ -327,7 +327,7 @@ func (v *Vault) Tree() ([]Entry, error) {
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) {
root := v.root
if rel != "" {
@@ -371,10 +371,10 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
return entries, err
}
// ---- 移动 / 重命名 ----
// ---- Moving / renaming ----
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
// Move moves or renames a note, image, or folder and reports whether atomic rewriting changed the moved item's file identity.
// 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) {
src, err := v.abs(from)
if err != nil {
@@ -457,7 +457,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return false, err
}
}
// 笔记、目录和图片的移动都可能改变跨文件相对链接。
// Moving notes, folders, or images can change relative links between files.
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets)
if err != nil {
return false, err
@@ -517,7 +517,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return rewritten, err
}
// ---- 回收站 ----
// ---- Recycle bin ----
func newTrashID() string {
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}$`)
// Delete 把笔记(连同 .assets)、图片或文件夹移入回收站
// Delete moves a note (with .assets), image, or folder to the recycle bin.
func (v *Vault) Delete(rel string) (TrashItem, error) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -591,7 +591,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
rollbackFailed = true
}
}
// 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。
// Retain directories and metadata on rollback failure instead of deleting the last recovery information.
if !rollbackFailed {
if cleanup := os.RemoveAll(dir); cleanup != nil {
err = errors.Join(err, cleanup)
@@ -660,7 +660,7 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
for _, d := range dirs {
item, err := v.readTrashItem(d.Name())
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 {
return nil, err
@@ -711,7 +711,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
return
}
if cleaning {
// 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。
// Cleanup may have deleted metadata; recreate recovery information before undoing this move.
if rollback := os.MkdirAll(dir, 0o755); rollback != nil {
err = errors.Join(err, rollback)
return
@@ -756,12 +756,12 @@ func (v *Vault) Purge(id string) error {
return v.purgeTrashItemLocked(metadata)
}
// ---- 资源 ----
// ---- Resources ----
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
// SaveAsset 把上传的文件存入笔记同级的 assets 目录,以内容哈希命名(相同内容只存一份),
// 返回可直接写入 Markdown 的相对链接
// SaveAsset stores an upload in the note's sibling assets directory, named by content hash (identical contents are stored once),
// and returns a relative link ready for Markdown.
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
if !IsNote(note) {
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)
}
// 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) {
if !IsImage(origName) {
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
}
// 管理界面允许替换图片,文件名不保证内容未变;只复用实际内容相同的普通文件。
// 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) {
base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
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 {
return linkEscaper.Replace(s)
}
+2 -2
View File
@@ -18,7 +18,7 @@ func newVault(t *testing.T) *Vault {
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 {
t.Helper()
dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note)))
@@ -85,7 +85,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
t.Fatal(err)
}
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"
if _, err := v.Move("n.md", destination); err == nil {
t.Fatal("图片目录移动应失败")
+13 -13
View File
@@ -1,5 +1,5 @@
// Package watch 监听仓库目录的变化(包括 RawMark 自身的写入和外部编辑器/同步工具的修改),
// 与上一次快照比较后把差异作为事件广播。所有变更都经由文件系统这一条路径,客户端据 rev 识别自己的写入。
// Package watch monitors vault changes, including RawMark writes and external editor/sync-tool changes,
// and broadcasts differences from the previous snapshot. All changes flow through the filesystem; clients recognize their own writes by rev.
package watch
import (
@@ -22,7 +22,7 @@ import (
const (
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
)
@@ -43,8 +43,8 @@ type Watcher struct {
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) {
fw, err := fsnotify.NewWatcher()
if err != nil {
@@ -82,7 +82,7 @@ func (w *Watcher) loop(ctx context.Context) {
if !ok {
return
}
// 事件队列溢出等情况会丢事件,立即全量扫描补齐
// Queue overflows can lose events; immediately rescan to recover them.
slog.Warn("文件监听出错,执行全量扫描", "err", err)
timer.Reset(debounce)
case <-timer.C:
@@ -126,7 +126,7 @@ func notes(snap map[string]fileState) map[string]string {
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) {
entries, err := w.v.TreeAt("")
if err != nil {
@@ -145,7 +145,7 @@ func (w *Watcher) scan() (map[string]fileState, error) {
} else {
data, err := os.ReadFile(abs)
if errors.Is(err, fs.ErrNotExist) {
continue // 扫描期间被删除,下一轮会处理
continue // Deleted during scanning; the next pass will handle it.
}
if err != nil {
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 {
if !dirs[d] {
delete(w.watched, d) // 目录已删除,inotify 会自动移除监听
delete(w.watched, d) // The directory was deleted; inotify removes its watch automatically.
}
}
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 {
var evs []events.Event
structural := false
@@ -184,7 +184,7 @@ func diff(prev, next map[string]fileState) []events.Event {
structural = true
removed[p] = old
}
candidates := map[string]string{} // 新路径 -> 唯一的原路径
candidates := map[string]string{} // New path -> unique original path.
destinations := map[string]int{}
for p, cur := range next {
old, existed := prev[p]
@@ -206,7 +206,7 @@ func diff(prev, next map[string]fileState) []events.Event {
case old.dir != cur.dir:
structural = true
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) {
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)
}
}
// 先应用父目录映射;子项另有改名时,从映射后的路径继续移动。
// Apply parent-directory mappings first; independently renamed children then move from their mapped paths.
if move.From != move.Path {
emitted = append(emitted, move)
}
+13 -13
View File
@@ -1,13 +1,13 @@
#!/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]
# --host 前端监听所有网卡,便于手机/平板通过局域网访问
# Usage: scripts/dev.sh [--host]
# --host Listen on all interfaces for LAN access from phones/tablets.
#
# 环境变量:
# RAWMARK_DATA 开发数据目录,默认 <仓库>/.dev-data
# API_PORT 后端端口,默认 8080(Vite 代理读取同一变量)
# WEB_PORT 前端端口,默认 5173
# Environment variables:
# RAWMARK_DATA Development data directory; defaults to <repository>/.dev-data.
# API_PORT Backend port; defaults to 8080 (also used by the Vite proxy).
# WEB_PORT Frontend port; defaults to 5173.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -32,7 +32,7 @@ for arg in "$@"; do
esac
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"
fail() {
@@ -54,11 +54,11 @@ port_in_use() {
port_in_use "$API_PORT" && fail "端口 $API_PORT 已被占用(后端)"
port_in_use "$WEB_PORT" && fail "端口 $WEB_PORT 已被占用(前端)"
# 由 pnpm 核对锁定依赖;已有依赖时复用本地缓存。
# Let pnpm verify locked dependencies and reuse cached packages when available.
echo "==> 核对前端依赖"
pnpm -C web install --frozen-lockfile
# 退出时结束本脚本启动的所有子进程(包括 go run 编译出的服务进程)
# On exit, stop every child started here, including the service compiled by go run.
cleanup() {
trap - EXIT INT TERM
echo
@@ -67,13 +67,13 @@ cleanup() {
}
trap cleanup EXIT INT TERM
# 给两路日志加前缀,便于区分
# Prefix both log streams to distinguish them.
prefix() { sed -u "s/^/[$1] /"; }
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 &
# 等后端就绪后再启动前端,避免首次编译期间前端请求失败
# Wait for the backend before starting Vite to avoid failed requests during initial compilation.
for _ in $(seq 1 120); do
curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 && break
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"
(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
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""从指定 v7.9 ZIP 重建本地字体:python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip。
仅重建字体时需要 fonttools[woff];应用构建直接使用已入库的 WOFF2。
来源:https://github.com/subframe7536/maple-font/releases/tag/v7.9
"""Rebuild local fonts from the specified v7.9 ZIP: python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip.
Only font regeneration requires fonttools[woff]; application builds use the checked-in WOFF2 files.
Source: https://github.com/subframe7536/maple-font/releases/tag/v7.9
"""
import io
from pathlib import Path
@@ -15,14 +15,14 @@ root = Path(__file__).resolve().parents[1] / 'web/src/common/appearance'
output = root / 'fonts/maple'
output.mkdir(exist_ok=True)
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:
(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')]:
data = archive.read('MapleMonoNormalNL-NF-CN-' + variant + '.ttf')
source = TTFont(io.BytesIO(data))
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}):
unicodes = [cp for cp in points if cp // 4096 == block]
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;
DROP TABLE IF EXISTS share_items;
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;
UPDATE settings SET value = json_remove(value, '$.shortcuts') WHERE key = 'app.settings';
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
## 适用全部文件
## Apply to all files.
[*]
### 设置字符集
### Character encoding.
charset = utf-8
### 缩进风格 space | tab,建议 space
### Indentation style: space | tab; prefer spaces.
indent_style = space
### 缩进的空格数
### Spaces per indentation level.
indent_size = 2
### 换行符类型 lf | cr | crlf,一般都是设置为 lf
### Line endings: lf | cr | crlf; normally use lf.
end_of_line = lf
### 是否在文件末尾插入空白行
### Ensure a final newline.
insert_final_newline = true
### 是否删除一行中的前后空格
### Remove trailing whitespace.
trim_trailing_whitespace = true
## 适用 .md 文件
## Apply to Markdown files.
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
+1 -1
View File
@@ -1,5 +1,5 @@
# China mirror of npm
registry = https://registry.npmmirror.com
# 安装依赖时锁定版本号
# Pin exact dependency versions on installation.
save-exact = true
+4 -4
View File
@@ -1,7 +1,7 @@
//go:build !dev
// Package web 把前端构建产物(web/dist,由 pnpm build 生成)嵌入 Go 二进制。
// 开发时使用 -tags dev,前端由 Vite 开发服务器提供,见 embed_dev.go。
// Package web embeds the frontend build (web/dist, generated by pnpm build) in the Go binary.
// Use -tags dev during development; Vite serves the frontend, as described in embed_dev.go.
package web
import (
@@ -17,12 +17,12 @@ var dist embed.FS
func Dist() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err) // 路径为编译期常量,不会失败
panic(err) // The compile-time constant path cannot fail.
}
return sub
}
// BuildVersion 与页面入口使用同一构建标识,生产产物缺失时明确失败。
// BuildVersion shares the page entry's build identifier and fails explicitly when production output is missing.
func BuildVersion() (string, error) {
html, err := fs.ReadFile(Dist(), "index.html")
if err != nil {
+4 -4
View File
@@ -9,16 +9,16 @@ import (
"regexp"
)
// Dist 在开发模式下直接读取磁盘上的 web/dist(未构建时为空,页面由 Vite 开发服务器提供),
// 因此无需先构建前端即可 go run
// Dist reads web/dist from disk in development (empty before building; Vite serves pages),
// so go run does not require a frontend build first.
func Dist() fs.FS {
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 }
// 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) {
root := filepath.Join("web", "src", "common", "appearance")
css, err := os.ReadFile(filepath.Join(root, "fonts.css"))
+9 -9
View File
@@ -1,26 +1,26 @@
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(
{
// 使用外部格式化程序格式化 css、html、markdown 等文件
// Use external formatters for CSS, HTML, Markdown, and similar files.
formatters: true,
// 启用样式规则
// Enable stylistic rules.
stylistic: {
// 缩进级别
// Indentation width.
indent: 2,
// 引号风格 'single' | 'double'
// Quote style: 'single' | 'double'.
quotes: 'single',
// 是否启用分号
// Whether to require semicolons.
semi: false,
},
// 忽略文件
// Ignored files.
ignores: ['dist/**', 'types/auto/**', 'public/**', 'src/common/appearance/fonts/**', 'src/common/appearance/maple-font.css', 'LICENSE'],
},
{
// 对所有文件都生效的规则
// Rules applied to all files.
rules: {
// 保留项目现有紧凑分支及浏览器原生危险操作确认。
// Preserve existing compact branches and native browser confirmation for destructive actions.
'style/max-statements-per-line': 'off',
'no-alert': 'off',
// vue
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"io/fs"
)
// FontStorage 读取构建工具统计的内置可选字体大小,不包含默认 Maple。
// FontStorage reads build-generated sizes for optional built-in fonts, excluding default Maple.
func FontStorage() (int64, error) {
data, err := fs.ReadFile(Dist(), "font-storage.json")
if err != nil {
+1 -1
View File
@@ -4,7 +4,7 @@ allowBuilds:
'@parcel/watcher': true
esbuild: true
# CodeMirror/Lezer 类型和扩展必须共享同一实例,与迁移前版本保持一致。
# CodeMirror/Lezer types and extensions must share one instance, preserving pre-migration versions.
overrides:
'@codemirror/commands': 6.11.1
'@codemirror/lang-markdown': 6.5.2
+1 -1
View File
@@ -10,7 +10,7 @@ import { navigationError, router } from '@/router'
const user = useUserStore()
function retry() {
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>
+5 -5
View File
@@ -2,7 +2,7 @@ import type * as Assets from './type'
import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios'
/** 上传笔记图片。 */
/** Upload an image for a note. */
export function uploadImageApi(data: Assets.UploadImageRequestData) {
const body = new FormData()
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) {
const body = new FormData()
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) {
const body = new FormData()
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) {
return request<Assets.ImageResponseData>({
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) {
return request<Assets.CleanupImagesResponseData>({
url: 'assets/cleanup',
+8 -8
View File
@@ -1,7 +1,7 @@
import type * as Auth from './type'
import { request } from '@/http/axios'
/** 查询初始化状态。 */
/** Query initialization status. */
export function getAuthStateApi() {
return request<Auth.StateResponseData>({
url: 'auth/state',
@@ -11,7 +11,7 @@ export function getAuthStateApi() {
})
}
/** 签发或刷新登录验证码。 */
/** Issue or refresh a sign-in captcha. */
export function getCaptchaApi(data: Auth.CaptchaRequestData) {
return request<Auth.CaptchaResponseData>({
url: 'auth/captcha',
@@ -22,7 +22,7 @@ export function getCaptchaApi(data: Auth.CaptchaRequestData) {
})
}
/** 初始化登录密码。 */
/** Initialize the login password. */
export function setupApi(data: Auth.SetupRequestData) {
return request<Auth.LoginResponseData>({
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) {
return request<Auth.LoginResponseData>({
url: 'auth/login',
@@ -44,7 +44,7 @@ export function loginApi(data: Auth.LoginRequestData) {
})
}
/** 恢复当前身份与权限。 */
/** Restore the current identity and permissions. */
export function getCurrentUserApi() {
return request<Auth.IdentityResponseData>({
url: 'auth/me',
@@ -53,7 +53,7 @@ export function getCurrentUserApi() {
})
}
/** 撤销当前服务端会话。 */
/** Revoke the current server session. */
export function logoutApi() {
return request<void>({
url: 'auth/logout',
@@ -61,7 +61,7 @@ export function logoutApi() {
})
}
/** 签发受限资源票据。 */
/** Issue restricted resource tickets. */
export function createResourceTicketApi(data: Auth.TicketRequestData) {
return request<Auth.TicketResponseData>({
url: 'auth/tickets',
@@ -71,7 +71,7 @@ export function createResourceTicketApi(data: Auth.TicketRequestData) {
})
}
/** 修改登录密码。 */
/** Change the login password. */
export function changePasswordApi(data: Auth.PasswordRequestData) {
return request<Auth.PasswordResponseData>({
url: 'auth/password',
+1 -1
View File
@@ -2,7 +2,7 @@ import type { EventHandlers } from './type'
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 { pinia } from '@/pinia'
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 { request } from '@/http/axios'
/** 读取 Markdown 原文与版本。 */
/** Read original Markdown and its revision. */
export function getFileApi(path: string) {
return request<string>({
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) {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
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) {
return request<Files.DeleteFileResponseData>({
url: `files/${encodePath(path)}`,
@@ -37,7 +37,7 @@ export function deleteFileApi(path: string) {
})
}
/** 创建文件夹。 */
/** Create a folder. */
export function createFolderApi(data: Files.FileRequestData) {
return request<Files.FileOperationResponseData>({
url: 'folders',
@@ -47,7 +47,7 @@ export function createFolderApi(data: Files.FileRequestData) {
})
}
/** 移动文件或目录。 */
/** Move a file or folder. */
export function moveFileApi(data: Files.MoveRequestData) {
return request<Files.FileOperationResponseData>({
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) {
const body = new FormData()
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) {
return request<Blob>({
url: 'export',
@@ -83,7 +83,7 @@ export function exportFilesApi(data: Files.ExportRequestData) {
})
}
/** 下载原始文件。 */
/** Download a raw file. */
export function getRawFileApi(path: string) {
return request<Blob>({
url: `raw/${encodePath(path)}`,
+13 -13
View File
@@ -1,7 +1,7 @@
import type * as Maintenance from './type'
import { request } from '@/http/axios'
/** 读取维护状态。 */
/** Read maintenance status. */
export function getMaintenanceStatusApi() {
return request<Maintenance.MaintenanceResponseData>({
url: 'maintenance/status',
@@ -10,7 +10,7 @@ export function getMaintenanceStatusApi() {
})
}
/** 导出实例或设置备份。 */
/** Export an instance or settings backup. */
export function exportBackupApi(kind: Maintenance.BackupKind) {
return request<Blob>({
url: `maintenance/export/${kind}`,
@@ -21,7 +21,7 @@ export function exportBackupApi(kind: Maintenance.BackupKind) {
})
}
/** 导出全部 Markdown 笔记。 */
/** Export all Markdown notes. */
export function exportNotesApi() {
return request<Blob>({
url: 'maintenance/notes/export',
@@ -32,7 +32,7 @@ export function exportNotesApi() {
})
}
/** 导出系统日志。 */
/** Export system logs. */
export function exportLogsApi() {
return request<Blob>({
url: 'maintenance/logs/export',
@@ -43,7 +43,7 @@ export function exportLogsApi() {
})
}
/** 创建备份上传。 */
/** Create a backup upload. */
export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) {
return request<Maintenance.BackupUploadResponseData>({
url: 'maintenance/uploads',
@@ -53,7 +53,7 @@ export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData)
})
}
/** 顺序上传备份分块。 */
/** Upload backup chunks sequentially. */
export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
return request<Maintenance.BackupChunkResponseData>({
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) {
return request<Maintenance.BackupSummaryResponseData>({
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) {
return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}`,
@@ -82,7 +82,7 @@ export function cancelBackupUploadApi(id: string) {
})
}
/** 导入已确认的设置备份。 */
/** Import a confirmed settings backup. */
export function importSettingsApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/import',
@@ -92,7 +92,7 @@ export function importSettingsApi(id: string) {
})
}
/** 恢复已确认的实例备份。 */
/** Restore a confirmed instance backup. */
export function restoreBackupApi(id: string) {
return request<Maintenance.RestoreBackupResponseData>({
url: 'maintenance/restore',
@@ -102,7 +102,7 @@ export function restoreBackupApi(id: string) {
})
}
/** 备份后重置设置。 */
/** Back up and reset settings. */
export function resetSettingsApi() {
return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/reset',
@@ -112,7 +112,7 @@ export function resetSettingsApi() {
})
}
/** 优化或重建派生索引。 */
/** Optimize or rebuild the derived index. */
export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/index/${operation}`,
@@ -121,7 +121,7 @@ export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
})
}
/** 清理非活动临时文件。 */
/** Clean inactive temporary files. */
export function clearTempApi() {
return request<Maintenance.ClearTempResponseData>({
url: 'maintenance/temp/clear',
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as Metadata from './type'
import { request } from '@/http/axios'
/** 读取笔记元数据索引。 */
/** Read indexed note metadata. */
export function getMetadataApi() {
return request<Metadata.MetadataResponseData>({
url: 'metadata',
+6 -6
View File
@@ -1,7 +1,7 @@
import type * as Plugins from './type'
import { request } from '@/http/axios'
/** 读取已安装插件。 */
/** List installed plugins. */
export function getPluginsApi() {
return request<Plugins.PluginsResponseData>({
url: 'plugins',
@@ -10,7 +10,7 @@ export function getPluginsApi() {
})
}
/** 安装插件 ZIP。 */
/** Install a plugin ZIP. */
export function installPluginApi(file: File) {
const data = new FormData()
data.append('file', file)
@@ -22,7 +22,7 @@ export function installPluginApi(file: File) {
})
}
/** 启用或停用插件。 */
/** Enable or disable a plugin. */
export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
return request<Plugins.PluginResponseData>({
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) {
return request<Plugins.PluginOperationResponseData>({
url: `plugins/${encodeURIComponent(id)}`,
@@ -41,7 +41,7 @@ export function uninstallPluginApi(id: string) {
})
}
/** 读取插件存储。 */
/** Read plugin storage. */
export function getPluginDataApi(id: string) {
return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(id)}/data`,
@@ -50,7 +50,7 @@ export function getPluginDataApi(id: string) {
})
}
/** 保存插件存储。 */
/** Save plugin storage. */
export function savePluginDataApi(data: Plugins.PluginDataRequestData) {
return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(data.id)}/data`,
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as Search from './type'
import { request } from '@/http/axios'
/** 搜索笔记。 */
/** Search notes. */
export function searchApi(params: Search.SearchRequestData) {
return request<Search.SearchResponseData>({
url: 'search',
+1 -1
View File
@@ -1,7 +1,7 @@
export interface SearchRequestData { q: string }
export interface SearchResult {
path: string
/** 第一处匹配行,从1开始;只有路径匹配时为0。 */
/** First matching line, starting at 1; 0 when only the path matches. */
line: number
snippet: string
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type * as Settings from './type'
import { request } from '@/http/axios'
/** 读取设置。 */
/** Read settings. */
export function getSettingsApi() {
return request<Settings.SettingsResponseData>({
url: 'settings',
@@ -10,7 +10,7 @@ export function getSettingsApi() {
})
}
/** 更新设置。 */
/** Update settings. */
export function patchSettingsApi(data: Settings.SettingsData) {
return request<Settings.SettingsResponseData>({
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 SettingsResponseData = ApiResponseData<SettingsData>
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as Storage from './type'
import { request } from '@/http/axios'
/** 统计实际存储占用。 */
/** Measure actual storage usage. */
export function getStorageApi() {
return request<Storage.StorageResponseData>({
url: 'storage',
+2 -2
View File
@@ -1,7 +1,7 @@
import type * as Themes from './type'
import { request } from '@/http/axios'
/** 读取可选主题。 */
/** List available themes. */
export function getThemesApi() {
return request<Themes.ThemesResponseData>({
url: 'themes',
@@ -10,7 +10,7 @@ export function getThemesApi() {
})
}
/** 读取主题与样式。 */
/** Read a theme and its styles. */
export function getThemeApi(id: string) {
return request<Themes.ThemeResponseData>({
url: `themes/${encodeURIComponent(id)}`,
+3 -3
View File
@@ -1,7 +1,7 @@
import type * as Tokens from './type'
import { request } from '@/http/axios'
/** 读取 API 令牌列表。 */
/** List API tokens. */
export function getTokensApi() {
return request<Tokens.TokensResponseData>({
url: 'tokens',
@@ -10,7 +10,7 @@ export function getTokensApi() {
})
}
/** 创建 API 令牌。 */
/** Create an API token. */
export function createTokenApi(data: Tokens.CreateTokenRequestData) {
return request<Tokens.CreateTokenResponseData>({
url: 'tokens',
@@ -20,7 +20,7 @@ export function createTokenApi(data: Tokens.CreateTokenRequestData) {
})
}
/** 撤销 API 令牌。 */
/** Revoke an API token. */
export function deleteTokenApi(id: string) {
return request<Tokens.DeleteTokenResponseData>({
url: `tokens/${encodeURIComponent(id)}`,
+4 -4
View File
@@ -1,7 +1,7 @@
import type * as Trash from './type'
import { request } from '@/http/axios'
/** 读取回收站。 */
/** Read the recycle bin. */
export function getTrashApi() {
return request<Trash.TrashResponseData>({
url: 'trash',
@@ -10,7 +10,7 @@ export function getTrashApi() {
})
}
/** 恢复回收站条目。 */
/** Restore a recycle-bin entry. */
export function restoreTrashApi(id: string) {
return request<Trash.RestoreTrashResponseData>({
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() {
return request<Trash.ClearTrashResponseData>({
url: 'trash',
@@ -28,7 +28,7 @@ export function clearTrashApi() {
})
}
/** 永久删除回收站条目。 */
/** Permanently delete a recycle-bin entry. */
export function purgeTrashApi(id: string) {
return request<Trash.PurgeTrashResponseData>({
url: `trash/${encodeURIComponent(id)}`,
+2 -2
View File
@@ -1,7 +1,7 @@
import type * as Tree from './type'
import { request } from '@/http/axios'
/** 读取目录树。 */
/** Read the directory tree. */
export function getTreeApi() {
return request<Tree.TreeResponseData>({
url: 'tree',
@@ -10,7 +10,7 @@ export function getTreeApi() {
})
}
/** 设置或重置目录项图标。 */
/** Set or reset a tree entry's icon. */
export function setTreeIconApi(data: Tree.TreeIconRequestData) {
return request<Tree.TreeIconResponseData>({
url: 'tree-icon',
+1 -1
View File
@@ -56,7 +56,7 @@ function render() {
root.dataset.rmColorMode = dark ? 'dark' : 'light'
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
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())
if (!appearancePaused) {
for (const snippet of current.cssSnippets) {
+18 -18
View File
@@ -2,7 +2,7 @@
@import 'highlight.js/styles/atom-one-dark.css';
@import './maple-font.css';
/* 基础主题。--rm-* 变量继续作为主题接口,字体在本地加载。 */
/* Base theme. --rm-* variables remain the theme interface; fonts load locally. */
:root {
color-scheme: light;
--rm-sidebar-width: 288px;
@@ -10,7 +10,7 @@
--rm-font-heading: var(--rm-font-editor, var(--rm-font-text));
--rm-font-mono: 'Maple Mono Normal NL NF CN', monospace;
--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-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
@@ -159,7 +159,7 @@ textarea {
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) {
line-height: 1.4;
padding-top: 1rem;
@@ -186,7 +186,7 @@ textarea {
font-size: 1em;
}
/* Markdown 标记(激活行显示源码时) */
/* Markdown markers shown as source on active lines. */
.cm-rm-markup,
.cm-rm-url {
color: var(--rm-text-muted);
@@ -198,7 +198,7 @@ textarea {
cursor: text;
}
/* 行内代码 */
/* Inline code. */
.cm-rm-inline-code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
@@ -208,8 +208,8 @@ textarea {
border-radius: 4px;
}
/* 代码块
* 背景画在 ::before 上并置于选区图层之下:若直接给 .cm-line 设背景,会盖住 CodeMirror 绘制的选区 */
/* Code blocks. Paint backgrounds on ::before below the selection
* layer; a background on .cm-line would cover CodeMirror's selection. */
.cm-line.cm-rm-code {
position: relative;
font-family: var(--rm-font-mono);
@@ -246,7 +246,7 @@ textarea {
.cm-line.cm-rm-code-begin.cm-rm-code-end::before {
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 {
display: inline-flex;
align-items: center;
@@ -291,14 +291,14 @@ textarea {
color: var(--rm-text-muted);
}
/* 引用 */
/* Blockquotes. */
.cm-line.cm-rm-quote {
border-left: 3px solid var(--rm-accent);
padding: 5px 15px;
background: color-mix(in srgb, var(--rm-accent) 4%, transparent);
}
/* 列表 */
/* Lists. */
.cm-rm-bullet {
color: var(--rm-text-muted);
display: inline-block;
@@ -311,7 +311,7 @@ textarea {
cursor: pointer;
}
/* 分隔线 */
/* Thematic breaks. */
.cm-rm-hr {
display: inline-block;
width: 100%;
@@ -319,7 +319,7 @@ textarea {
vertical-align: middle;
}
/* 图片 */
/* Images. */
.cm-rm-image {
display: block;
}
@@ -332,7 +332,7 @@ textarea {
font-style: italic;
}
/* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */
/* Tables show monospaced source while the cursor is inside, otherwise an editable rendered table. */
.cm-line.cm-rm-table {
font-family: var(--rm-font-mono);
font-size: 0.9em;
@@ -379,7 +379,7 @@ textarea {
.cm-rm-table-widget a {
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 {
display: flex;
justify-content: flex-end;
@@ -452,7 +452,7 @@ textarea {
.cm-editor .cm-content {
padding: 16px 16px 40vh;
}
/* 触屏没有悬停,工具栏常显,按钮加大触控热区 */
/* Touchscreens lack hover: keep the toolbar visible and enlarge button touch targets. */
.cm-rm-table-toolbar {
visibility: visible;
opacity: 1;
@@ -463,7 +463,7 @@ textarea {
}
}
/* ---- Typora 扩展语法 ---- */
/* ---- Typora syntax extensions ---- */
.cm-rm-highlight,
.cm-rm-table-widget mark {
background: var(--rm-highlight-bg);
@@ -505,7 +505,7 @@ textarea {
font-size: 0.9em;
color: var(--rm-text-muted);
}
/* 编辑时显示在源码下方的预览 */
/* Preview displayed below source while editing. */
.cm-rm-preview {
margin: 4px 0 8px;
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) {
opacity: 0;
pointer-events: none;
+3 -3
View File
@@ -1,5 +1,5 @@
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() {
const root = document.documentElement
const fonts = document.fonts
@@ -22,7 +22,7 @@ export function watchFontLoading() {
function ready() {
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()
else if (fonts.status === 'loaded') root.dataset.rmFonts = 'ready'
}
@@ -34,7 +34,7 @@ export function watchFontLoading() {
})
fonts.addEventListener('loadingerror', failed)
// 挂载完成后触发一次排版,确保等待的是当前文字用到的字体。
// Trigger layout after mounting so the wait covers fonts used by the current text.
requestAnimationFrame(() => {
document.body.getBoundingClientRect()
void fonts.ready.then(ready)
+1 -1
View File
@@ -13,7 +13,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
if (!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')
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-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; }
+1 -1
View File
@@ -1,5 +1,5 @@
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' }
export const TEXT_FONTS = {
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,
body {
height: 100%;
}
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
/* Use the visible viewport height when the software keyboard opens; see viewport.ts. */
#app {
height: var(--rm-app-height, 100%);
display: flex;
@@ -52,7 +52,7 @@ button {
color: inherit;
}
/* ---- 通用控件 ---- */
/* ---- Shared controls ---- */
.rm-btn {
display: inline-flex;
align-items: center;
@@ -121,7 +121,7 @@ button {
color: var(--rm-text-muted);
}
/* ---- 登录 ---- */
/* ---- Sign-in ---- */
.rm-auth {
display: flex;
align-items: center;
@@ -159,7 +159,7 @@ button {
font-size: var(--rm-ui-font-small);
}
/* ---- 布局 ---- */
/* ---- Layout ---- */
.rm-shell {
display: flex;
flex-direction: column;
@@ -220,7 +220,7 @@ button {
transition: none;
}
/* ---- 大纲 ---- */
/* ---- Outline ---- */
.rm-outline-backdrop {
display: flex;
flex: none;
@@ -292,7 +292,7 @@ button {
display: none;
}
/* ---- 侧栏 ---- */
/* ---- Sidebar ---- */
.rm-sidebar-inner {
box-sizing: border-box;
display: flex;
@@ -346,7 +346,7 @@ button {
--rm-tree-row-overlay: var(--rm-hover);
background: var(--rm-hover);
}
/* 长按由应用处理,不弹出 iOS 的系统菜单 */
/* The application handles long presses instead of showing the iOS system menu. */
.rm-tree-row {
-webkit-touch-callout: none;
}
@@ -403,7 +403,7 @@ button {
opacity: 0;
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 {
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);
text-align: center;
}
/* ---- 搜索 ---- */
/* ---- Search ---- */
.rm-search {
flex: 1;
display: flex;
@@ -515,7 +515,7 @@ button {
color: var(--rm-text);
border-radius: 2px;
}
/* ---- 快速切换 ---- */
/* ---- Quick switcher ---- */
.rm-switcher-list {
flex: 1;
overflow: auto;
@@ -583,7 +583,7 @@ button {
color: var(--rm-text);
}
/* ---- 菜单 ---- */
/* ---- Menus ---- */
.rm-menu {
position: absolute;
top: 100%;
@@ -626,7 +626,7 @@ button {
cursor: default;
}
/* ---- 标签页 ---- */
/* ---- Tabs ---- */
.rm-tabs {
display: flex;
gap: 2px;
@@ -733,7 +733,7 @@ button {
border-radius: 50%;
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 {
display: block;
}
@@ -747,7 +747,7 @@ button {
opacity: 1 !important;
}
/* ---- 提示条 ---- */
/* ---- Status bars ---- */
.rm-banner {
display: flex;
flex-wrap: wrap;
@@ -767,7 +767,7 @@ button {
color: var(--rm-warn-text);
}
/* ---- 编辑区 ---- */
/* ---- Editor area ---- */
.rm-editor-pane {
flex: 1;
min-height: 0;
@@ -780,7 +780,7 @@ button {
height: 100%;
}
/* ---- 对话框 ---- */
/* ---- Dialogs ---- */
.rm-dialog-head {
display: flex;
align-items: center;
@@ -823,7 +823,7 @@ button {
font-size: var(--rm-ui-font-small);
user-select: all;
}
/* ---- 设置 ---- */
/* ---- Settings ---- */
.rm-setting {
display: flex;
align-items: center;
@@ -849,7 +849,7 @@ button {
transform: rotate(180deg);
}
/* ---- 通知 ---- */
/* ---- Notifications ---- */
.rm-toasts {
position: fixed;
left: 50%;
@@ -883,7 +883,7 @@ button {
padding: 0;
}
/* ---- 移动端 ---- */
/* ---- Mobile ---- */
@media (max-width: 767px) {
.rm-sidebar {
position: fixed;
@@ -966,7 +966,7 @@ button {
.rm-sidebar-resize {
display: none;
}
/* 大纲在手机上为从右侧滑入的浮层 */
/* On phones, the outline is an overlay sliding in from the right. */
.rm-outline-backdrop {
display: block;
position: fixed;
@@ -984,12 +984,12 @@ button {
padding-top: env(safe-area-inset-top);
box-shadow: var(--rm-shadow);
}
/* 触控热区不小于 44px */
/* Touch targets must be at least 44px. */
.rm-tree-row {
height: var(--rm-control-size);
font-size: inherit;
}
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
/* iOS Safari zooms focused inputs when their font size is below 16px. */
.rm-search-input {
height: var(--rm-control-size);
font-size: var(--rm-ui-font-size);
@@ -1001,14 +1001,14 @@ button {
.rm-search-item {
padding: 10px 8px;
}
/* 手机上从顶部弹出,给软键盘留出空间 */
/* Open from the top on phones to leave room for the software keyboard. */
.rm-switcher-item {
height: var(--rm-control-size);
font-size: var(--rm-ui-font-size);
}
}
/* 主题与 CSS 编辑 */
/* Theme and CSS editing. */
.rm-appearance-note {
margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4);
font-size: var(--rm-ui-font-small);
@@ -1195,7 +1195,7 @@ button {
background: var(--rm-active);
}
/* 轻玻璃工作台 */
/* Glass workspace. */
.rm-vault-heading {
display: flex;
align-items: center;
@@ -1348,7 +1348,7 @@ button {
min-height: 0;
position: relative;
}
/* 选区工具栏与局部外观面板 */
/* Selection toolbar and local appearance panel. */
.rm-selection-popover {
position: fixed;
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 {
display: grid;
grid-template-rows: 1fr;
@@ -39,7 +39,7 @@
gap: inherit;
}
/* 复用 RawMark 主题,弹出层与页面使用相同的字体、色彩和密度。 */
/* Reuse the RawMark theme so popovers and pages share fonts, colors, and density. */
:root {
--el-font-size-small: var(--rm-ui-font-small);
--el-font-size-extra-small: var(--rm-ui-font-small);
@@ -20,7 +20,7 @@ async function update() {
return
}
if (!activated.value && worker) {
// 等待新版激活后,仅刷新主动点击更新的页面。
// After the new version activates, refresh only the page that requested the update.
await new Promise<void>((resolve, reject) => {
const finish = () => {
if (worker.state !== 'activated' && worker.state !== 'redundant') return
@@ -39,7 +39,7 @@ async function update() {
})
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()
else toast(translate('common.some_changes_or_operations_are_a5fda9'))
} catch {
@@ -67,7 +67,7 @@ async function importFont(event: Event) {
try {
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'))
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
// 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()
try {
await new FontFace('rawmark-import-check', data).load()
@@ -22,7 +22,7 @@ function onSystem() {
system.addEventListener('change', onSystem)
onBeforeUnmount(() => system.removeEventListener('change', onSystem))
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
? ['#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'])
+1 -1
View File
@@ -8,7 +8,7 @@ const CACHE_LIMIT = 500
let worker: Worker | undefined
let nextId = 1
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>()
function getWorker(): Worker {
+3 -3
View File
@@ -1,4 +1,4 @@
// 主线程与高亮 Worker 之间的消息格式
// Message format between the main thread and highlight Worker.
export interface HighlightRequest {
id: number
@@ -6,8 +6,8 @@ export interface HighlightRequest {
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 })[]
// 语言不受支持时 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 }
+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 { afterAll, beforeAll, expect, it, vi } from 'vitest'
+2 -2
View File
@@ -1,7 +1,7 @@
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
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 {
const root: TokenRun = []
const stack = [root]
@@ -23,7 +23,7 @@ function serialize(html: string): TokenRun {
function highlight(req: HighlightRequest): TokenRun | null {
if (!hljs.getLanguage(req.lang)) return null
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
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