chore: standardize project comments in English
This commit is contained in:
1 parent
a874a39ea5
commit
ccb16acbea
167 files changed
+820
-820
No files matched your search
+4
-4
@@ -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,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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestReplaceImageAPI(t *testing.T) {
|
||||
if err != nil || string(data) != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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() }
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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", "[](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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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
@@ -1,5 +1,5 @@
|
||||
# China mirror of npm
|
||||
registry = https://registry.npmmirror.com
|
||||
|
||||
# 安装依赖时锁定版本号
|
||||
# Pin exact dependency versions on installation.
|
||||
save-exact = true
|
||||
+4
-4
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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,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,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
|
||||
}
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
/** 保留插件写入的未知设置,应用端按已知字段校验。 */
|
||||
/** Preserve unknown plugin settings; the application validates known fields. */
|
||||
export type SettingsData = Record<string, unknown>
|
||||
export type SettingsResponseData = ApiResponseData<SettingsData>
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user