chore: standardize project comments in English

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

No files matched your search

+21 -21
View File
@@ -1,4 +1,4 @@
// Package api 提供 REST API(/api/v1)、事件推送与前端静态资源。前端只通过这套 API 访问数据。
// Package api serves the REST API (/api/v1), events, and frontend assets. The frontend accesses data only through this API.
package api
import (
@@ -37,9 +37,9 @@ type Server struct {
store *store.Store
hub *events.Hub
index *search.Index
// 串行化设置的"读取-合并-写入"
// Serialize settings read/merge/write operations.
settingsMu sync.Mutex
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
// ThemesDir points to the data directory's themes folder; an empty value enables only built-in themes.
DataDir string
ThemesDir string
PluginsDir string
@@ -62,14 +62,14 @@ func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, inde
return &Server{vault: v, auth: a, store: st, hub: hub, index: index, captchas: newCaptchas()}
}
// access 为接口的访问要求
// access defines an endpoint's access requirement.
type access int
const (
public access = iota
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
// Login session or API token (read-only tokens may use GET and read-only exports).
authed
// 只允许登录会话:管理 API 令牌,避免令牌给自己扩权
// Login sessions only: prevent API tokens from expanding their own permissions through token management.
sessionOnly
)
@@ -79,7 +79,7 @@ type route struct {
handler http.HandlerFunc
}
// routes 为全部接口;openapi.json 须与之一致(见 TestOpenAPICoversRoutes)
// routes lists all endpoints; openapi.json must match it (see TestOpenAPICoversRoutes).
func (s *Server) routes() []route {
return []route{
{"GET /api/v1/health", public, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }},
@@ -170,7 +170,7 @@ func (s *Server) Handler(static fs.FS) http.Handler {
return localized(securityHeaders(sameOrigin(s.guardAPI(mux))))
}
// ---- 中间件 ----
// ---- Middleware ----
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -185,8 +185,8 @@ func securityHeaders(next http.Handler) http.Handler {
})
}
// sameOrigin 保持写请求的同源边界;Bearer 鉴权不依赖浏览器 Cookie。
// 没有 Origin 头的请求来自非浏览器客户端,放行
// sameOrigin preserves the same-origin boundary for writes; Bearer authentication does not rely on browser Cookies.
// Requests without Origin come from non-browser clients and are allowed.
func sameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
@@ -201,7 +201,7 @@ func sameOrigin(next http.Handler) http.Handler {
})
}
// ---- 鉴权 ----
// ---- Authentication ----
func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
setUp, err := s.auth.IsSetUp()
@@ -276,7 +276,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"permissions": []string{"vault:read", "vault:write", "instance:manage"}, "expiresAt": identity(r).ExpiresAt})
}
// ---- 文件 ----
// ---- Files ----
func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
entries, err := s.vault.Tree()
@@ -331,7 +331,7 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
w.Write(data)
}
// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖
// writeFile uses the entire request body as note content. If-Match: "rev" requires a matching revision; If-None-Match: * requires a new file; omitting both overwrites unconditionally.
func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
if err != nil {
@@ -371,10 +371,10 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
return
}
if rewritten {
// 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。
// Atomic rewriting replaced the file identity, so the watcher can no longer identify this move from its snapshot.
s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"})
} else {
// 图标提交在文件移动之后;不能仅依赖文件监听器较早的通知。
// Icons commit after the file move; the watcher's earlier notification alone is insufficient.
s.hub.Broadcast(events.Event{Type: "tree"})
}
writeJSON(w, 200, map[string]bool{"ok": true})
@@ -392,7 +392,7 @@ func (s *Server) mkdir(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]bool{"ok": true})
}
// uploadAsset 接收 multipart 表单中的 file 字段,存入指定笔记或目录下的共享 assets 目录。
// uploadAsset accepts the multipart file field and saves it in the shared assets directory for the selected note or folder.
func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
file, header, err := r.FormFile("file")
@@ -418,7 +418,7 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"link": link})
}
// replaceImage 仅替换已存在的图片;路径与引用保持不变。
// replaceImage replaces only an existing image, preserving its path and references.
func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
@@ -444,7 +444,7 @@ func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]bool{"ok": true})
}
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
// raw serves any non-hidden vault file (mainly images). A sandbox CSP prevents SVG/HTML scripts from running on this origin.
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
s.serveRaw(w, r, r.PathValue("path"))
}
@@ -465,7 +465,7 @@ func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) {
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
// ---- 回收站 ----
// ---- Recycle bin ----
func (s *Server) trashList(w http.ResponseWriter, r *http.Request) {
items, err := s.vault.TrashList()
@@ -494,9 +494,9 @@ func (s *Server) trashPurge(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]bool{"ok": true})
}
// ---- 响应工具 ----
// ---- Response helpers ----
// clientIP 返回限速用的来源地址(未经反向代理头部,避免伪造)
// clientIP returns the source address for rate limiting, ignoring proxy headers to prevent spoofing.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
+5 -5
View File
@@ -33,7 +33,7 @@ type fixture struct {
root string
store *store.Store
hub *events.Hub
// 已登录的客户端
// Authenticated client.
admin *http.Client
}
@@ -88,7 +88,7 @@ func TestEventsRejectRevokedCredentials(t *testing.T) {
t.Fatal(err)
}
defer conn.CloseNow()
// 首条事件确认订阅已经建立,避免把握手和广播的时序当作鉴权结果。
// The first event confirms subscription before testing authorization, avoiding handshake/broadcast timing races.
f.hub.Broadcast(events.Event{Type: "tree"})
if _, _, err := conn.Read(ctx); err != nil {
t.Fatal(err)
@@ -238,7 +238,7 @@ func decode[T any](t *testing.T, res *http.Response) T {
return envelope.Data
}
// 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败
// Documentation must match actual endpoints: missing new routes and obsolete documented routes both fail.
func TestOpenAPICoversRoutes(t *testing.T) {
var spec struct {
Paths map[string]map[string]json.RawMessage
@@ -271,7 +271,7 @@ func TestOpenAPICoversRoutes(t *testing.T) {
func TestAPIResponsesAreNotCached(t *testing.T) {
f := newFixture(t)
f.write("笔记.md", "# 正文")
// 登录状态与目录曾在 Firefox 离线时命中 HTTP 缓存,误进入无法加载的工作区。
// Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace.
for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} {
res := f.do(f.admin, "GET", path, "", nil)
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" {
@@ -443,7 +443,7 @@ func TestSetupWithoutCode(t *testing.T) {
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
t.Fatalf("重复初始化必须拒绝: %d", status)
}
// 新建 Manager 模拟重启,不能重新开放密码设置。
// A new Manager simulates a restart and must not reopen password setup.
reopened := auth.New(st)
if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) {
t.Fatalf("重启后不能覆盖密码: %v", err)
+5 -5
View File
@@ -9,11 +9,11 @@ import (
"github.com/coder/websocket"
)
// events 通过 WebSocket 推送仓库变更。websocket.Accept 默认校验 Origin 与 Host 一致
// events pushes vault changes over WebSocket. websocket.Accept checks that Origin matches Host by default.
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
s.eventWG.Add(1)
defer s.eventWG.Done()
// 先订阅再完成握手,避免连接建立后首条变更落在订阅空窗中。
// Subscribe before completing the handshake so the first change cannot fall into a subscription gap.
msgs, unsubscribe := s.hub.Subscribe()
defer unsubscribe()
conn, err := websocket.Accept(w, r, nil)
@@ -36,9 +36,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
}
return true
}
// 客户端不发消息;CloseRead 负责处理控制帧并在连接断开时取消 ctx
// Clients send no messages; CloseRead handles control frames and cancels ctx on disconnect.
ctx := conn.CloseRead(r.Context())
// 定期 ping,避免反向代理因空闲断开连接
// Ping periodically to prevent idle disconnects by reverse proxies.
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
@@ -47,7 +47,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
return
case msg, ok := <-msgs:
if !ok {
// 客户端消费过慢被移出广播列表,断开后由客户端重连并全量同步
// Disconnect slow subscribers removed from the hub; clients reconnect and perform a full sync.
conn.Close(websocket.StatusTryAgainLater, "too slow")
return
}
+1 -1
View File
@@ -76,7 +76,7 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) {
t.Fatal(err)
}
}
// 与真实进程一致,导出暂存文件位于应用数据目录内。
// As in the real process, export temporary files live inside the application data directory.
t.Setenv("TMPDIR", filepath.Join(data, "temp"))
res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil)
defer res.Body.Close()
+1 -1
View File
@@ -235,7 +235,7 @@ func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
return
}
defer root.Close()
// 先撤下资源;设置写失败时恢复,避免留下指向已删除字体的设置。
// Remove the resource first and restore it if settings fail to save, avoiding references to a deleted font.
removed := ".remove-" + id
if err := root.Rename(id, removed); err != nil {
internalError(w, r, err)
+1 -1
View File
@@ -79,7 +79,7 @@ func TestReplaceImageAPI(t *testing.T) {
if err != nil || string(data) != "![](assets/a.png)" {
t.Fatalf("替换不应改变引用:%q %v", data, err)
}
// 复用现有移动与回收站接口,图片不会被当作 Markdown 正文读取。
// Reuse move and recycle-bin endpoints without reading images as Markdown text.
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"assets/a.png","to":"assets/b.png"}`, nil); code != 200 {
t.Fatal(code)
}
+5 -5
View File
@@ -42,7 +42,7 @@ func publicImageIP(ip netip.Addr) bool {
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
return false
}
// 不接受 IPv6 地址转换前缀,避免其中嵌入的 IPv4 地址访问内网。
// Reject IPv6 translation prefixes so embedded IPv4 addresses cannot reach private networks.
if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) {
return false
}
@@ -64,7 +64,7 @@ func checkImageURL(u *url.URL) error {
var remoteImageClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
// 不使用环境代理,确保每个实际连接都经过目标地址校验。
// Ignore environment proxies so every actual connection passes destination-address validation.
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error {
@@ -105,7 +105,7 @@ var remoteImageClient = &http.Client{
},
}
// readImageDownload 按实际文件签名确定类型,不信任来源的扩展名或 Content-Type。
// readImageDownload determines type from file signatures, not the source extension or Content-Type.
func readImageDownload(body io.Reader) ([]byte, string, error) {
data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1))
if err != nil {
@@ -174,7 +174,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return
}
// url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。
// url.Error includes the full source URL, potentially with temporary signatures; log only its underlying cause.
var urlError *url.Error
if errors.As(err, &urlError) {
err = urlError.Err
@@ -208,7 +208,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
}
return
}
// 下载期间笔记可能被外部程序移动或删除;不能在旧路径下创建孤立资源。
// External programs may move or delete the note during download; do not create orphaned resources at the old path.
info, err := s.vault.Stat(note)
if err != nil {
vaultError(w, r, err)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
// 总请求上限包含表单开销,文件总量另由 vault 校验。
// The total request limit includes form overhead; vault validates the aggregate file size separately.
r.Body = http.MaxBytesReader(w, r.Body, vault.MaxImportBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
var limit *http.MaxBytesError
+1 -1
View File
@@ -95,7 +95,7 @@ func TestImportImageDownloadAndRedirects(t *testing.T) {
transport := original.Transport.(*http.Transport).Clone()
dial := transport.DialContext
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
// 只把固定的测试来源映射到本地真实 HTTP 服务,其他目标仍走生产地址校验。
// Map only the fixed test origin to a real local HTTP server; other destinations retain production address validation.
if address == "images.example:80" {
return (&net.Dialer{}).DialContext(ctx, network, source.Listener.Addr().String())
}
+1 -1
View File
@@ -1,4 +1,4 @@
package api
// CloseEvents 在数据库关闭前断开并等待全部 WebSocket 处理结束。
// CloseEvents disconnects and waits for all WebSocket handlers before databases close.
func (s *Server) CloseEvents() { s.hub.Close(); s.eventWG.Wait() }
+2 -2
View File
@@ -29,7 +29,7 @@ type maintenanceStatus struct {
cause error
}
// gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。
// gate drains ordinary API operations before maintenance; WebSockets do not hold long-lived read locks.
func (s *Server) guardAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/v1/health" {
@@ -324,7 +324,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
return values, nil
}
// importSettings 在确认后合并资源;原资源与数据库值都保留到成功提交为止。
// importSettings merges resources after confirmation, retaining original resources and database values until commit succeeds.
func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string
+3 -3
View File
@@ -71,7 +71,7 @@ func uploadedPackage(t *testing.T, f *fixture, kind string, data []byte) string
t.Fatalf("创建上传失败: %d", res.StatusCode)
}
id := decode[struct{ ID string }](t, res).ID
// 跨多个分块验证偏移,不引入测试开关。
// Verify offsets across multiple chunks without introducing test switches.
offset := 0
for offset < len(data) {
end := min(offset+137, len(data))
@@ -192,7 +192,7 @@ func TestFullBackupRestoresFilesDatabaseAndInvalidatesSessions(t *testing.T) {
root := t.TempDir()
vault := filepath.Join(root, "vault")
os.MkdirAll(vault, 0700)
// 解包后落入目标实例,目标的环境路径与源实例不同。
// Extract into the target instance, whose environment paths differ from the source.
if err = backup.CopyTree(filepath.Join(stage, "vault"), vault); err != nil {
t.Fatal(err)
}
@@ -273,7 +273,7 @@ func TestSettingsPackageDatabaseFailureRollsBack(t *testing.T) {
f.store.SetSetting(settingsKey, `{"fontSize":24}`)
data := exportedPackage(t, f, "settings")
f.store.SetSetting(settingsKey, `{"fontSize":18}`)
// 通过实际 SQLite 约束制造提交失败,验证备份和事务边界。
// Use an actual SQLite constraint to force commit failure and verify backup and transaction boundaries.
raw, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db"))
if err != nil {
t.Fatal(err)
+4 -4
View File
@@ -181,7 +181,7 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
}
func backupResourceError(err error) error {
var pathError *os.PathError
// 缺失文件或文件/目录类型错误来自归档内容;其他磁盘故障仍由服务端处理。
// Missing files or file/directory type mismatches are archive errors; other disk failures remain server errors.
if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) {
return err
}
@@ -368,7 +368,7 @@ type cleanupResult struct {
func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
result := cleanupResult{Failed: []string{}}
err := s.maintain("clean_temporary_files_af91ae", func() error {
// temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。
// temp belongs to this process; ordinary APIs and maintenance have drained. Restore archives and rollback backups live elsewhere.
temp, err := s.tempDir()
if err != nil {
return err
@@ -381,7 +381,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
for _, entry := range entries {
targets = append(targets, filepath.Join(temp, entry.Name()))
}
// 只清理过期未使用上传;当前会话新上传和待恢复内容不会被清除。
// Clean only expired unused uploads; retain new uploads from the current session and pending restore contents.
uploads := filepath.Join(s.DataDir, ".maintenance", "uploads")
entries, err = os.ReadDir(uploads)
if err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -450,7 +450,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, result)
}
// 仅用于服务启动前读取恢复结果,不以空响应伪装已恢复。
// Read restore results only before service startup; never disguise an empty response as a completed restore.
func (s *Server) SetRestoreFailure(err error) {
s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock()
+2 -2
View File
@@ -38,7 +38,7 @@ func TestOpenAPIResponseContracts(t *testing.T) {
}
return op
}
// 下载、原文、资源代理与 OpenAPI 本身不使用业务 JSON 外壳。
// Downloads, raw files, resource proxies, and OpenAPI itself do not use the business JSON envelope.
raw := map[string]bool{
"get /openapi.json": true, "get /files/{path}": true, "get /raw/{path}": true,
"get /plugins/{id}/assets/{revision}/{path}": true, "get /fonts/{id}/file": true,
@@ -133,7 +133,7 @@ func TestOpenAPIResponseContracts(t *testing.T) {
check("get", "/maintenance/status", f.do(f.admin, "GET", "/api/v1/maintenance/status", "", nil))
check("post", "/maintenance/uploads", f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":10}`, nil))
id := stagedPackage(t, f, "full", exportedPackage(t, f, "full"))
// 仅验证提交契约;合成实例不触发真实进程重启或恢复执行。
// Verify only the submission contract; the synthetic instance must not trigger a real process restart or restore.
f.server.Restart = func() {}
check("post", "/maintenance/restore", f.do(f.admin, "POST", "/api/v1/maintenance/restore", `{"id":"`+id+`","confirmed":true}`, nil))
}
+1 -1
View File
@@ -124,7 +124,7 @@ func TestPluginInstallBoundariesAndData(t *testing.T) {
if res := pluginUpload(t, f, f.admin, large); res.StatusCode != 400 {
t.Fatalf("解压超限得到 %d", res.StatusCode)
}
// 令牌即使有写权限,也不能植入或读取插件代码及私有配置。
// Even write-capable API tokens cannot install or read plugin code or private configuration.
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"测试","scope":"write"}`, nil)).Token
for _, target := range []string{"/api/v1/plugins", base + "/data", asset} {
if code := f.status(guest, "GET", target, "", map[string]string{"Authorization": "Bearer " + token}); code != 403 {
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"rawmark/internal/events"
)
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端合并与保存,并校验由服务端执行的清理期限
// Application settings are one JSON object shared across devices. The frontend defines contents; the server merges, saves, and validates server-enforced retention.
const settingsKey = "app.settings"
func (s *Server) readSettings() (map[string]json.RawMessage, error) {
@@ -34,8 +34,8 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, settings)
}
// patchSettings 按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。
// 不同设备同时修改不同的设置不会互相覆盖;合并后通知其他设备重新读取
// patchSettings merges top-level keys: supplied values replace existing values, and null removes a key.
// Concurrent changes to different settings do not overwrite each other; notify other devices to reload after merging.
func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes))
if err != nil {
@@ -86,7 +86,7 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, settings)
}
// changePassword 修改登录密码;当前会话保留,其他设备需重新登录
// changePassword changes the login password, retaining the current session and requiring other devices to sign in again.
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
var body struct{ Current, Next string }
if !readJSON(w, r, &body) {
+8 -8
View File
@@ -12,19 +12,19 @@ import (
"time"
)
// 应用页面的 CSP:CodeMirror 通过 <style> 注入样式,需要 'unsafe-inline';
// 笔记中可引用外部图片,img-src 放开 http/https
// Application CSP: CodeMirror injects <style> elements and requires 'unsafe-inline';
// notes may reference external images, so img-src allows http/https.
const appCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob: https: http:; connect-src 'self'; worker-src 'self' blob:; font-src 'self' data:; " +
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
type staticFile struct {
data []byte
gzipped []byte // 压缩后不变小时为 nil
gzipped []byte // nil when compression does not reduce size.
}
// staticHandler 提供前端构建产物:带哈希的 assets/ 长期缓存,其余每次校验;
// 文本资源首次请求时压缩并缓存在内存(嵌入的文件不会变化)
// staticHandler serves frontend builds: hashed assets/ files use long-lived caching; other files revalidate on every request.
// Text resources are compressed and cached in memory on first request (embedded files never change).
type staticHandler struct {
fsys fs.FS
started time.Time
@@ -42,14 +42,14 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// 已移除的公开入口不能回退到应用首页。
// Removed public entry points must not fall back to the application home page.
if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
writeError(w, 404, message(w, "page_not_found_e75737"))
return
}
f, err := h.load(name)
if err != nil {
// 前端路由:未知路径一律返回入口页面
// Frontend routing: unknown paths return the entry page.
name = "index.html"
if f, err = h.load(name); err != nil {
writeError(w, 404, message(w, "frontend_resources_are_missing_2c9b3e"))
@@ -66,7 +66,7 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
hd.Set("Content-Security-Policy", appCSP)
}
if path.Ext(name) == ".webmanifest" {
// Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。
// Go's MIME table may lack this extension; do not let ServeContent sniff the compressed bytes.
hd.Set("Content-Type", "application/manifest+json")
} else if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
hd.Set("Content-Type", ct)
+3 -3
View File
@@ -39,8 +39,8 @@ func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, usage)
}
// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。
// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。
// Count logical bytes of regular files only, without following internal symlinks; scan external vaults separately.
// For nested roots, assign each file to the more specific root to avoid double counting.
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) {
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
if dataDir == "" {
@@ -80,7 +80,7 @@ func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, e
}
if walkErr != nil {
if path != root && errors.Is(walkErr, fs.ErrNotExist) {
return nil // 文件可能在扫描期间被移动或删除。
return nil // Files may move or disappear during the scan.
}
return walkErr
}
+1 -1
View File
@@ -19,7 +19,7 @@
--rm-highlight-bg: rgba(232, 196, 119, 0.25);
}
/* 绿色按钮使用深色文字,保持按钮的文字对比度。 */
/* Use dark text on green buttons to preserve text contrast. */
.rm-btn-primary,
.rm-btn-primary:hover,
.rm-segmented button.on {
+1 -1
View File
@@ -40,7 +40,7 @@ func TestThemeBoundaries(t *testing.T) {
if !strings.Contains(theme["css"], "#faf6ed") {
t.Fatal("未读取到主题 CSS")
}
// CSS 符号链接不能读取主题目录之外的应用数据。
// CSS symlinks must not expose application data outside the theme directory.
if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -19,7 +19,7 @@ func (s *Server) listTokens(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"tokens": tokens})
}
// createToken 返回的 token 明文只出现这一次,服务器只保存哈希
// createToken returns the plaintext token only once; the server stores only its hash.
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, Scope string }
if !readJSON(w, r, &body) {
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"time"
)
// JSON/JavaScript 共同精确表示的非负整数范围;不乘天数,避免期限溢出。
// Nonnegative integers exactly representable in both JSON and JavaScript; avoid multiplying by days, which could overflow.
const maxRetentionDays = 1<<53 - 1
func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) {
@@ -40,7 +40,7 @@ func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
func (s *Server) cleanupTrash(now time.Time) error {
s.activity.RLock()
defer s.activity.RUnlock()
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。
// Serialize with settings writes so cleanup cannot start with stale retention after "never delete" is saved.
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
settings, err := s.readSettings()
@@ -64,7 +64,7 @@ func (s *Server) cleanupTrash(now time.Time) error {
return nil
}
// RunTrashCleanup 启动时执行一次,其后每小时执行;不依赖浏览器在线。
// RunTrashCleanup runs at startup and hourly thereafter, independently of browser connections.
func (s *Server) RunTrashCleanup(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
+14 -14
View File
@@ -1,4 +1,4 @@
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及 API 令牌的校验。
// Package auth implements single-user login (Argon2id hashes, session tokens, rate limiting) and API-token validation.
package auth
import (
@@ -25,13 +25,13 @@ const (
SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8
// RFC 9106 推荐的第二组参数(内存受限环境)
// The second recommended parameter set in RFC 9106 (memory-constrained environments).
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
// 同一来源在窗口内累计失败 maxFailures 次即封禁 lockout
// Block a source for lockout after maxFailures failures within the window.
maxFailures = 30
failureWindow = 15 * time.Minute
lockout = 15 * time.Minute
@@ -49,7 +49,7 @@ type Manager struct {
store *store.Store
mu sync.Mutex
failures map[string][]time.Time
blocked map[string]time.Time // 来源 -> 解封时间
blocked map[string]time.Time // Source -> unblock time.
now func() time.Time
}
@@ -63,7 +63,7 @@ func (m *Manager) IsSetUp() (bool, error) {
}
func (m *Manager) Setup(password string) error {
// 已初始化时提前拒绝,避免公开接口重复计算密码哈希;并发首次写入仍由数据库保证原子性。
// Reject initialized instances early to avoid repeated public password hashing; the database still makes concurrent first writes atomic.
setUp, err := m.IsSetUp()
if err != nil {
return err
@@ -85,12 +85,12 @@ func (m *Manager) Setup(password string) error {
return nil
}
// Login 校验密码并创建会话,返回明文令牌(仅存哈希)
// Login validates the password and creates a session, returning the plaintext token (only its hash is stored).
func (m *Manager) Login(ip, password string) (string, error) {
return m.AttemptLogin(ip, password, nil)
}
// AttemptLogin 在同一限流边界校验登录挑战与密码;初始化无需挑战。
// AttemptLogin validates the login challenge and password within one rate-limit boundary; setup needs no challenge.
func (m *Manager) AttemptLogin(ip, password string, challenge func() error) (string, error) {
if !m.allow(ip) {
return "", ErrRateLimited
@@ -138,7 +138,7 @@ func (m *Manager) SessionExpiry(token string) (time.Time, bool, error) {
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
// ChangePassword 校验当前密码后设置新密码,并注销 keepToken 以外的所有会话
// ChangePassword verifies the current password, sets the new one, and revokes all sessions except keepToken.
func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
if !m.allow(ip) {
return ErrRateLimited
@@ -164,9 +164,9 @@ func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
return m.store.DeleteOtherSessions(hashToken(keepToken))
}
// ---- API 令牌 ----
// ---- API tokens ----
// CreateAPIToken 创建令牌,返回只显示这一次的明文
// CreateAPIToken creates a token and returns its plaintext value for one-time display.
func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) {
if scope != "read" && scope != "write" {
return "", store.APIToken{}, ErrBadScope
@@ -179,7 +179,7 @@ func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, er
return plain, t, nil
}
// ValidateAPIToken 返回令牌的权限范围;无效时 ok 为 false
// ValidateAPIToken returns a token's scope; ok is false for an invalid token.
func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) {
if !strings.HasPrefix(plain, apiTokenPrefix) {
return "", false, nil
@@ -187,7 +187,7 @@ func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err err
return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli())
}
// ---- 限速:同一来源在时间窗口内失败过多则封禁一段时间 ----
// ---- Rate limiting: temporarily block sources with too many failures within the window ----
func (m *Manager) allow(ip string) bool {
m.mu.Lock()
@@ -226,7 +226,7 @@ func (m *Manager) clearFailures(ip string) {
m.mu.Unlock()
}
// ---- 密码与令牌 ----
// ---- Passwords and tokens ----
func hashPassword(password string) string {
salt := make([]byte, 16)
@@ -272,7 +272,7 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:])
}
// randomCode 生成形如 ABCD-EFGH 的 API 令牌标识,去掉易混淆字符
// randomCode generates an API-token identifier such as ABCD-EFGH, excluding ambiguous characters.
func randomCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8)
+2 -2
View File
@@ -25,7 +25,7 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
return m, &clock
}
// 登录密码累计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝
// Thirty failed password attempts block the source for fifteen minutes, even with the correct password.
func TestRateLimit(t *testing.T) {
m, clock := newManager(t)
for i := 0; i < maxFailures-1; i++ {
@@ -52,7 +52,7 @@ func TestRateLimit(t *testing.T) {
}
}
// 窗口外的失败不计入;登录成功清零
// Failures outside the window do not count; successful login resets the counter.
func TestRateLimitWindow(t *testing.T) {
m, clock := newManager(t)
for i := 0; i < maxFailures-1; i++ {
+4 -4
View File
@@ -1,4 +1,4 @@
// Package backup 处理 RawMark 自有的实例与设置归档;普通 Markdown 导出由 vault 负责。
// Package backup handles RawMark instance and settings archives; vault handles ordinary Markdown exports.
package backup
import (
@@ -23,7 +23,7 @@ const MaxArchiveBytes int64 = 2 << 30
const MaxExpandedBytes int64 = 8 << 30
const MaxEntries = 100000
// ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。
// ErrInvalidArchive denotes a backup-contract violation, excluding disk or filesystem failures.
var ErrInvalidArchive = i18n.New("invalid_backup_contents_ecff07")
type Entry struct {
@@ -59,7 +59,7 @@ func validEntry(kind, name string) bool {
return kind == "settings" && name == "settings.json"
}
// Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。
// Create does not follow symlinks and explicitly fails if any file cannot be read.
func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) {
if kind != "full" && kind != "settings" {
return i18n.New("invalid_backup_type_f5ff70")
@@ -182,7 +182,7 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
return f.Sync()
}
// Extract 在私有暂存目录校验完整清单和全部文件哈希,绝不写入活动数据目录。
// Extract verifies the full manifest and all file hashes in private staging, never writing to active data directories.
func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
var manifest Manifest
z, err := zip.OpenReader(archive)
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"sync"
)
// LogWriter 保留三份各不超过 10 MiB 的应用日志,权限 0600。
// LogWriter retains three application log files of at most 10 MiB each, with mode 0600.
type LogWriter struct {
mu sync.Mutex
dir string
@@ -66,7 +66,7 @@ func (l *LogWriter) Write(p []byte) (int, error) {
}
func (l *LogWriter) Close() error { l.mu.Lock(); defer l.mu.Unlock(); return l.file.Close() }
// Snapshot 读取当前日志的稳定副本,避免导出与日志轮转竞争。
// Snapshot reads stable copies of current logs to avoid races with rotation during export.
func (l *LogWriter) Snapshot(dest string) error {
l.mu.Lock()
defer l.mu.Unlock()
+9 -9
View File
@@ -17,8 +17,8 @@ import (
sqlite3 "modernc.org/sqlite/lib"
)
// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。
// prepared 尚未改动活动数据,applying 表示上次中断,必须先回退。
// RestoreRecord must run after HTTP, file watching, indexing, and databases have shut down.
// prepared means active data is unchanged; applying indicates an interrupted restore that must first roll back.
type RestoreRecord struct {
State string `json:"state"`
Kind string `json:"kind,omitempty"`
@@ -70,7 +70,7 @@ func Pending(data string) (bool, error) {
}
func CheckDatabase(file string) error {
// 先检查文件签名,Open 的 schema 初始化不能将错误文件伪装为新实例。
// Check file signatures first so Open's schema initialization cannot disguise an invalid file as a new instance.
f, err := os.Open(file)
if err != nil {
return err
@@ -122,7 +122,7 @@ func CheckDatabase(file string) error {
return nil
}
// VaultExcluded 支持外置与嵌套仓库,避免将应用文件重复备份为笔记。
// VaultExcluded supports external and nested vaults, avoiding duplicate application files in note backups.
func VaultExcluded(data, vault, rel string) bool {
for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
if strings.HasPrefix(part, ".rawmark-tmp-") {
@@ -164,7 +164,7 @@ func copyFile(src, dst string, mode os.FileMode) error {
return errors.Join(err, out.Close())
}
// CopyTree 不覆盖任何已有目标,也不允许跟随链接。
// CopyTree never overwrites an existing destination or follows symlinks.
func CopyTree(src, dest string) error {
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil {
@@ -205,7 +205,7 @@ func clearVault(data, root, dir string) error {
if VaultExcluded(data, root, rel) {
continue
}
// data 在某个祖先目录内时保留祖先,只清理其余子项。
// When data lies inside an ancestor directory, keep that ancestor and clean only the other children.
if entry.IsDir() && strings.HasPrefix(data, full+string(filepath.Separator)) {
if err = clearVault(data, root, full); err != nil {
return err
@@ -258,7 +258,7 @@ func apply(data, vault, stage string) error {
return errors.Join(err, st.Close())
}
// Recover 在启动时完成预先确认的恢复;若中断或失败,使用操作前备份回退。
// Recover performs a previously confirmed restore at startup, reverting to the pre-operation backup on interruption or failure.
func Recover(data, vault string) (err error) {
raw, err := os.ReadFile(recordPath(data))
if errors.Is(err, os.ErrNotExist) {
@@ -274,7 +274,7 @@ func Recover(data, vault string) (err error) {
if record.State != "prepared" && record.State != "applying" && record.State != "verifying" {
return i18n.New("invalid_restore_record_state_eee063")
}
// 记录由本机生成;仍限制其读取范围,避免错误记录读取任意文件。
// The record is locally generated, but restrict its read scope to prevent malformed records from reading arbitrary files.
if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) {
return i18n.New("invalid_restore_record_path_611881")
}
@@ -315,7 +315,7 @@ func Recover(data, vault string) (err error) {
return nil
}
// FinishRecovery 在新实例的索引、监听与端口初始化成功后提交恢复。
// FinishRecovery commits the restore after the new instance's index, watcher, and listener initialize successfully.
func FinishRecovery(data string) error {
raw, err := os.ReadFile(recordPath(data))
if errors.Is(err, os.ErrNotExist) {
+9 -9
View File
@@ -1,4 +1,4 @@
// Package events 把仓库变更广播给所有在线客户端(WebSocket)。
// Package events broadcasts vault changes to all connected clients over WebSocket.
package events
import (
@@ -7,13 +7,13 @@ import (
"sync"
)
// Event 为推送给客户端的消息
// Event is a message pushed to clients.
//
// tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树
// changed 笔记内容变化,Rev 为新版本
// deleted 笔记被删除
// moved 文件或目录被移动(From -> Path),目录事件包含其子项的路径映射
// settings 应用设置变化,客户端重新读取
// tree Directory structure changed (create/delete/move); clients reload the tree.
// changed Note contents changed; Rev contains the new revision.
// deleted A note was deleted.
// moved A file or folder moved (From -> Path); folder events include child path mappings.
// settings Application settings changed; clients reload them.
type Event struct {
Type string `json:"type"`
Path string `json:"path,omitempty"`
@@ -29,7 +29,7 @@ type Hub struct {
func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} }
// Subscribe 返回消息通道与取消函数;通道在客户端跟不上时会被关闭,客户端应重连并全量同步
// Subscribe returns a message channel and cancel function. Slow clients are disconnected and must reconnect and fully sync.
func (h *Hub) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 64)
h.mu.Lock()
@@ -71,7 +71,7 @@ func (h *Hub) Broadcast(events ...Event) {
}
}
// Close 禁止新订阅,并通知现有连接关闭,供实例恢复重新打开数据库使用。
// Close rejects new subscriptions and closes existing connections so instance recovery can reopen databases.
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
+1 -1
View File
@@ -24,7 +24,7 @@ func insert(tx *sql.Tx, p, rev string, content []byte) (int64, error) {
return id, err
}
// Rebuild 与后台同步互斥,失败时事务和已知快照均保持原状。
// Rebuild excludes background synchronization; failures leave both the transaction and known snapshot unchanged.
func (x *Index) Rebuild(ctx context.Context) error {
x.work.Lock()
defer x.work.Unlock()
+1 -1
View File
@@ -41,7 +41,7 @@ func TestRebuildSupersedesQueuedOldSnapshot(t *testing.T) {
f := newFixture(t)
f.write("old.md", "old")
f.sync()
f.idx.Update(map[string]string{}) // 尚未消费的旧扫描不应在手动重建后删除新索引。
f.idx.Update(map[string]string{}) // An unconsumed old scan must not delete the new index after a manual rebuild.
f.write("new.md", "中文新笔记")
if err := f.idx.Rebuild(context.Background()); err != nil {
t.Fatal(err)
+16 -16
View File
@@ -1,5 +1,5 @@
// Package search 维护笔记的全文索引。索引存放在独立的数据库文件中,内容完全由仓库文件派生,
// 删除后会在下次启动时重建;结构变化时也直接重建而不做迁移。
// Package search maintains a full-text note index in a separate database, derived entirely from vault files.
// A deleted index is rebuilt at startup; structural changes also trigger rebuilding rather than migration.
package search
import (
@@ -20,7 +20,7 @@ import (
"rawmark/internal/vault"
)
// 表结构或派生元数据的解析规则变化时递增,旧索引会被丢弃重建。
// Increment when schema or derived-metadata parsing changes; old indexes are discarded and rebuilt.
const schemaVersion = 3
const schema = `
@@ -30,7 +30,7 @@ CREATE TABLE docs (
rev TEXT NOT NULL,
metadata TEXT NOT NULL
);
-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE
-- Trigram tokenization needs no word separators and supports Chinese fragments; use LIKE for terms shorter than three characters.
CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram');
`
@@ -42,7 +42,7 @@ const (
type Result struct {
Path string `json:"path"`
// 第一处匹配所在行(从 1 开始)与该行的摘录;只有文件名匹配时 Line 为 0
// First matching line (1-based) and its excerpt; Line is 0 when only the filename matches.
Line int `json:"line"`
Snippet string `json:"snippet"`
}
@@ -60,12 +60,12 @@ type Index struct {
v *vault.Vault
mu sync.Mutex
pending map[string]string // 待同步的最新快照(路径 -> rev)
pending map[string]string // Latest pending snapshot (path -> rev).
wake chan struct{}
pendingVersion uint64
work sync.Mutex // 串行化同步、重建和数据库维护
known map[string]doc // 已索引的笔记,仅由同步协程访问
work sync.Mutex // Serialize synchronization, rebuilding, and database maintenance.
known map[string]doc // Indexed notes, accessed only by the synchronization goroutine.
}
func Open(path string, v *vault.Vault) (*Index, error) {
@@ -112,7 +112,7 @@ func (x *Index) init() error {
func (x *Index) Close() error { return x.db.Close() }
// Update 提交仓库的最新快照(笔记路径 -> rev),立即返回;同步在后台进行,多次提交只处理最新一次
// Update submits the latest vault snapshot (note path -> rev) and returns immediately; background synchronization consumes only the latest submission.
func (x *Index) Update(files map[string]string) {
x.mu.Lock()
x.pending = files
@@ -124,7 +124,7 @@ func (x *Index) Update(files map[string]string) {
}
}
// Run 执行后台同步,直到 ctx 结束
// Run processes background synchronization until ctx ends.
func (x *Index) Run(ctx context.Context) {
for {
select {
@@ -143,7 +143,7 @@ func (x *Index) Run(ctx context.Context) {
}
}
// sync 让索引与快照一致:只读取新增或 rev 变化的笔记
// sync reconciles the index with the snapshot, reading only new notes or changed revisions.
func (x *Index) sync(files map[string]string) error {
x.work.Lock()
defer x.work.Unlock()
@@ -175,7 +175,7 @@ func (x *Index) syncLocked(files map[string]string) error {
}
content, rev, err := x.v.Read(p)
if errors.Is(err, vault.ErrNotFound) {
continue // 快照之后被删除,下一次快照会处理
continue // Deleted after the snapshot; the next snapshot will handle it.
}
if err != nil {
return err
@@ -210,8 +210,8 @@ func remove(tx *sql.Tx, id int64) error {
return err
}
// Search 查找同时包含所有词(空白分隔,不区分大小写)的笔记,词可出现在路径或正文中。
// 路径(文件名或目录名)命中第一个词的排在前面,其余按相关度排序
// Search finds notes containing every whitespace-separated term, case-insensitively, in paths or contents.
// Path matches (file or folder names) for the first term rank first; remaining results sort by relevance.
func (x *Index) Search(query string, limit int) ([]Result, error) {
terms := strings.Fields(query)
if len(terms) == 0 {
@@ -263,7 +263,7 @@ func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// snippet 返回正文中第一处匹配所在的行号与摘录(过长的行截取匹配附近的一段)
// snippet returns the first matching line number and an excerpt, truncating long lines around the match.
func snippet(body string, terms []string) (int, string) {
folded := make([][]rune, len(terms))
for i, t := range terms {
@@ -298,7 +298,7 @@ func snippet(body string, terms []string) (int, string) {
return 0, ""
}
// fold 逐字符转小写,保持字符数不变,便于在原文中按位置截取
// fold lowercases rune by rune, preserving character count for slicing the original text by position.
func fold(rs []rune) []rune {
out := make([]rune, len(rs))
for i, r := range rs {
+8 -8
View File
@@ -49,7 +49,7 @@ func (f *fixture) write(p, content string) {
}
}
// sync 模拟监听器提交快照
// sync simulates watcher snapshot submissions.
func (f *fixture) sync() {
f.t.Helper()
entries, err := f.v.Tree()
@@ -92,11 +92,11 @@ func TestSearchTerms(t *testing.T) {
f.sync()
cases := map[string]string{
"装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS
"检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE
"codemirror": "a.md", // 不区分大小写
"装饰器 检索": "目录/c.md", // 多个词同时满足
"目录": "目录/c.md", // 命中路径
"装饰器": "a.md,目录/c.md", // Use FTS for at least three characters.
"检索": "b.md,目录/c.md", // Use LIKE for fewer than three characters.
"codemirror": "a.md", // Case-insensitive.
"装饰器 检索": "目录/c.md", // Match every term.
"目录": "目录/c.md", // Path match.
"不存在的词": "",
"100%": "",
`"引号"`: "",
@@ -169,7 +169,7 @@ func TestSyncTracksChanges(t *testing.T) {
}
}
// 重启后沿用已有索引,只处理变化
// Reuse the existing index after restart and process only changes.
f.reopen()
if len(f.idx.known) != 2 {
t.Fatalf("重启后应载入已索引笔记: %v", f.idx.known)
@@ -210,7 +210,7 @@ func TestRebuildsLinkedImageMetadataFromOldIndex(t *testing.T) {
f := newFixture(t)
f.write("a.md", "[![预览](assets/used.png)](https://example.com)")
f.sync()
// 模拟旧版索引漏记图片引用;笔记正文和 rev 均不改变。
// Simulate an old index missing image references without changing note contents or revisions.
if _, err := f.idx.db.Exec(`UPDATE docs SET metadata = ?`, `{"title":"a","tags":[],"links":[],"assets":[],"warnings":[]}`); err != nil {
t.Fatal(err)
}
+4 -4
View File
@@ -9,7 +9,7 @@ import (
"strings"
)
// Snapshot 使用 SQLite 生成一致的独立数据库,不复制活动 WAL 文件。
// Snapshot uses SQLite to create a consistent standalone database rather than copying active WAL files.
func (s *Store) Snapshot(ctx context.Context, dest string) error {
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, dest)
return err
@@ -34,7 +34,7 @@ func (s *Store) Preferences() (map[string]json.RawMessage, error) {
return result, rows.Err()
}
// ReplacePreferences 一次提交全部偏好,账户与会话不属于设置包。
// ReplacePreferences commits all preferences together; accounts and sessions are excluded from settings archives.
func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
tx, err := s.db.Begin()
if err != nil {
@@ -60,7 +60,7 @@ func (s *Store) ResetPreferences() error {
}
func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err }
// CheckBackup 确认数据库可读取且包含当前版本所需表;不会初始化或修复传入文件。
// CheckBackup verifies readability and required tables without initializing or repairing the supplied database.
func (s *Store) CheckBackup() error {
var result string
if err := s.db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil {
@@ -81,7 +81,7 @@ func (s *Store) CheckBackup() error {
return nil
}
// OpenExisting 只打开已有库;导入校验不得创建表或更改日志模式。
// OpenExisting opens only existing databases; import validation must not create tables or change journal modes.
func OpenExisting(path string) (*Store, error) {
db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String())
if err != nil {
+8 -8
View File
@@ -1,4 +1,4 @@
// Package store 保存应用自身的数据(设置、会话、API 令牌)。笔记内容不在这里,见 vault。
// Package store persists application data (settings, sessions, API tokens). Note contents belong to vault.
package store
import (
@@ -55,7 +55,7 @@ func Open(path string) (*Store, error) {
func (s *Store) Close() error { return s.db.Close() }
// Setting 读取设置项;不存在时 ok 为 false
// Setting reads a setting; ok is false when it does not exist.
func (s *Store) Setting(key string) (value string, ok bool, err error) {
err = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
@@ -69,7 +69,7 @@ func (s *Store) SetSetting(key, value string) error {
return err
}
// SetSettingIfAbsent 仅在设置项不存在时写入,返回是否写入成功(用于首次初始化,防止并发重复设置)
// SetSettingIfAbsent writes only a missing key and reports whether it succeeded, preventing concurrent duplicate initialization.
func (s *Store) SetSettingIfAbsent(key, value string) (bool, error) {
res, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`, key, value)
if err != nil {
@@ -85,7 +85,7 @@ func (s *Store) CreateSession(tokenHash string, expires time.Time) error {
return err
}
// SessionExpiry 只读取有效会话,不延长固定有效期。
// SessionExpiry reads only valid sessions without extending their fixed expiry.
func (s *Store) SessionExpiry(tokenHash string, now time.Time) (time.Time, bool, error) {
var expires int64
err := s.db.QueryRow(`SELECT expires_at FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now.Unix()).Scan(&expires)
@@ -100,7 +100,7 @@ func (s *Store) DeleteSession(tokenHash string) error {
return err
}
// DeleteOtherSessions 删除除 keepHash 之外的所有会话(修改密码后让其他设备重新登录)
// DeleteOtherSessions deletes all sessions except keepHash, requiring other devices to sign in after a password change.
func (s *Store) DeleteOtherSessions(keepHash string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash)
return err
@@ -115,9 +115,9 @@ func (s *Store) DeleteExpiredSessions() error {
return err
}
// ---- API 令牌 ----
// ---- API tokens ----
// APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write
// APIToken is an automation access token stored only as a hash. Scope is read or write.
type APIToken struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -150,7 +150,7 @@ func (s *Store) APITokens() ([]APIToken, error) {
return out, rows.Err()
}
// UseAPIToken 按哈希查找令牌并记录使用时间;不存在时 ok 为 false
// UseAPIToken finds a token by hash and records its use time; ok is false when it does not exist.
func (s *Store) UseAPIToken(hash string, now int64) (scope string, ok bool, err error) {
err = s.db.QueryRow(`UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ? RETURNING scope`, now, hash).Scan(&scope)
if errors.Is(err, sql.ErrNoRows) {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"time"
)
// ResourceTicket 只保存资源凭据哈希,所属登录会话是授权的最终依据。
// ResourceTicket stores only a resource credential hash; the owning login session remains the final authority.
type ResourceTicket struct {
Hash string
SessionHash string
@@ -25,7 +25,7 @@ func (s *Store) Ticket(hash string, consume bool, now time.Time) (ResourceTicket
var t ResourceTicket
var expires int64
query := `SELECT token_hash,session_hash,kind,resource,revision,expires_at FROM resource_tickets WHERE token_hash = ? AND expires_at > ?`
// 事件握手票据原子消费;资源票据可在父会话有效期内读取。
// Event handshake tickets are consumed atomically; resource tickets remain readable while the parent session is valid.
if consume {
query = `DELETE FROM resource_tickets WHERE token_hash = ? AND expires_at > ? AND kind = 'events' RETURNING token_hash,session_hash,kind,resource,revision,expires_at`
}
+1 -1
View File
@@ -12,7 +12,7 @@ type CleanupSkip struct {
Cause error `json:"-"`
}
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
// CleanupImages rescans references under the write lock; the derived index never authorizes deletion.
func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
v.mu.Lock()
+4 -4
View File
@@ -14,8 +14,8 @@ import (
"strings"
)
// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。
// Export builds an export in a temporary file; the caller must close and remove it after success.
// ZIP entries retain vault-relative paths and unchanged text. Export only source files; do not parse or download external resources.
func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) {
if len(paths) == 0 {
return nil, "", i18n.New("w_choose_content_to_export_170d5e", ErrInvalidPath)
@@ -46,7 +46,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
files[path.Join(p, entry.Path)] = entry.Dir
}
}
// 仅收集已选笔记的附件,不追踪链接到的其他笔记。
// Collect attachments only for selected notes, without following links to other notes.
notes := make([]string, 0)
for p, dir := range files {
if !dir && IsNote(p) {
@@ -165,7 +165,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
return tmp, name, nil
}
// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。
// Attachments may use ../ to reference other folders, but cannot expose hidden paths or escape scope through symlinks.
func (v *Vault) exportPath(rel string) error {
full, err := v.abs(rel)
if err != nil {
+3 -3
View File
@@ -17,7 +17,7 @@ type MissingAsset struct {
Path string `json:"path"`
}
// exclude 由应用层提供,笔记库不持有数据库和资源目录的配置。
// exclude comes from the application layer; the vault does not own database or resource-directory configuration.
func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (result *os.File, err error) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -100,7 +100,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
return e
}
for _, target := range Analyze(rel, content).Assets {
// Analyze 的目标已解析为仓库相对路径,仅检查本地资源。
// Analyze resolves targets to vault-relative paths; inspect only local resources.
if strings.HasPrefix(target.Target, "/") || strings.Contains(target.Target, "://") {
continue
}
@@ -117,7 +117,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
return nil, err
}
if len(missing) > 0 {
// 报告放在内部目录,不与用户的普通 Markdown 文件重名;不修改原链接。
// Keep the report in an internal directory to avoid collisions with user Markdown files; preserve original links.
out, e := z.Create(path.Join(".rawmark-export", "missing-assets.json"))
if e != nil {
return nil, e
+3 -3
View File
@@ -13,7 +13,7 @@ import (
"strings"
)
// ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。
// ReplaceImage replaces an existing image in place, preserving reference paths; it cannot create files or change formats.
func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error {
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
return i18n.New("w_choose_a_file_with_8410e9", ErrInvalidPath)
@@ -52,7 +52,7 @@ type linkChange struct {
before, after []byte
}
// 调用方持有写锁;先计算全部改写,移动成功后再写入,出错时保留回滚依据。
// The caller holds the write lock. Compute all rewrites before moving, write them after success, and retain rollback information on error.
func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool, images map[string]string) ([]linkChange, error) {
entries, err := v.Tree()
if err != nil {
@@ -103,7 +103,7 @@ func movedPath(p, from, to string, directory bool) string {
return p
}
// 只替换链接地址,不重新序列化正文;移动和图片归位共用相同的代码/公式边界。
// Replace only link destinations without reserializing text; moves and image relocation share code/math boundaries.
func rewriteLocalLinks(content []byte, oldNote, newNote string, mapTarget func(string) string) ([]byte, error) {
var out []byte
last := 0
+7 -7
View File
@@ -28,7 +28,7 @@ const (
var ErrInvalidImport = i18n.New("invalid_import_contents_ed556a")
// ImportFile 的读取范围是单个上传文件,ZIP 与普通 Markdown 共用这一输入边界。
// ImportFile bounds reads to one uploaded file; ZIP and plain Markdown share this input boundary.
type ImportFile struct {
Name string
Reader io.ReaderAt
@@ -43,7 +43,7 @@ type ImportResult struct {
WarningMessages []*i18n.Error `json:"-"`
}
// Import 在隐藏目录内整理完整内容,提交前统一检查冲突,绝不覆盖已有文件。
// Import prepares complete contents in a hidden directory and checks all conflicts before commit, never overwriting existing files.
func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) {
result = ImportResult{Paths: []string{}, Warnings: []string{}}
if len(files) == 0 || len(files) > maxImportEntries {
@@ -82,7 +82,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
entries := map[string]struct{}{}
var total int64
var notes []string
// 每个来源文件只写一次;目录与文件重名也会在暂存阶段报错。
// Write each source file once; file/directory name collisions also fail during staging.
write := func(name string, isDir bool, reader io.Reader) error {
if err := ctx.Err(); err != nil {
return err
@@ -140,7 +140,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
for _, f := range z.File {
if f.Name == ".rawmark-export/missing-assets.json" {
continue
} // 本程序笔记导出的缺失附件报告,不作为笔记导入。
} // Do not import RawMark's missing-attachment export report as a note.
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
return result, i18n.New("w_zip_cannot_contain_symlinks_5f596f", ErrInvalidImport)
}
@@ -207,7 +207,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err
}
}
// 所有笔记改链完成后再清理已迁移的原图,避免后处理的笔记读取不到共享来源。
// Clean migrated source images only after rewriting all notes, so later notes can still read shared sources.
if len(migrated) > 0 {
used := map[string]bool{}
for _, note := range notes {
@@ -232,7 +232,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
}
}
result.Notes = len(notes)
// 附件数量为最终保留的文件,包含目录共享图片与其他原始附件。
// The attachment count covers final retained files, including folder-shared images and other original attachments.
count := 0
err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
if err != nil {
@@ -296,7 +296,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
return result, err
}
if zipped {
// 暂存目录与仓库位于同一文件系统,整个 ZIP 一次 rename 发布。
// Staging and the vault share a filesystem; publish the entire ZIP import with one rename.
err = os.Rename(stage, filepath.Join(parent, folder))
} else {
var published []string
+7 -7
View File
@@ -23,7 +23,7 @@ type importLink struct {
html bool
}
// 链接中的空白允许一次换行,但不能跨过段落。
// Whitespace in links may include one newline but cannot cross paragraphs.
func linkSpace(text []byte, pos int) int {
newline := false
for pos < len(text) {
@@ -42,7 +42,7 @@ func linkSpace(text []byte, pos int) int {
return pos
}
// linkDestination 返回地址区间和地址结束后的下标;地址本身不含尖括号。
// linkDestination returns the destination range and the following index; angle brackets are excluded from the destination.
func linkDestination(text []byte, pos int) (start, end, next int) {
start = linkSpace(text, pos)
if start == len(text) {
@@ -105,7 +105,7 @@ func linkTitleEnd(text []byte, pos int) int {
return -1
}
// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。
// importLinkSpans serves import, export, and image renaming, returning original byte positions without reserializing text.
func importLinkSpans(content []byte) []importLink {
var text []byte
var source []int
@@ -123,7 +123,7 @@ func importLinkSpans(content []byte) []importLink {
lastEnd = offset + len(line)
return line
})
// 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。
// Inline code can span lines. Mask its markers while preserving bracket structure around link labels.
masked := bytes.Repeat([]byte(" "), len(text))
pos := 0
for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) {
@@ -171,7 +171,7 @@ func importLinkSpans(content []byte) []importLink {
}
add(start, end, false)
definitionEnd = next
// 引用定义及其标题不是行内正文,避免再次扫描标题中的示例标记。
// Reference definitions and their titles are not inline body text; avoid rescanning example markers in titles.
for i := m[0]; i < next; i++ {
if text[i] != '\n' && text[i] != '\r' {
text[i] = ' '
@@ -214,7 +214,7 @@ func importLinkSpans(content []byte) []importLink {
add(start, end, false)
i = close
if open == 0 || text[open-1] != '!' {
brackets = nil // 链接不能嵌套,图片可以位于链接标签内。
brackets = nil // Links cannot nest, but images can appear inside link labels.
}
}
}
@@ -277,7 +277,7 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
if err != nil {
return nil, nil, err
}
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。
// Count only newly generated copies; existing attachments with the same name must match the hash-derived contents.
dir := path.Join(path.Dir(note), "assets")
dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data)
if err != nil {
+16 -16
View File
@@ -6,8 +6,8 @@ import (
"strings"
)
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。
// RewriteAssetLinks updates links from the old image directory (oldBase.assets/) after renaming a note.
// Handle inline links/images `](...)`, reference definitions `[x]: ...`, and HTML `src="..."`, skipping code and math.
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/")
@@ -43,17 +43,17 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
})
}
// transformMarkdownLines 复用链接改写的容器与代码块识别,避免导入时修改代码示例。
// transformMarkdownLines reuses container/code-block detection from link rewriting to preserve code examples during import.
func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform func(line, body []byte, offset int) []byte) []byte {
lines := bytes.SplitAfter(content, []byte("\n"))
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
var fence []byte // Current code fence string; nil when outside a fenced block.
var containers []linkContainer
paragraph := false
lineOffset := 0
for i, line := range lines {
offset := lineOffset
lineOffset += len(line)
// 分类时展开制表符,替换仍作用于原始字节,不重新序列化 Markdown。
// Expand tabs for classification, but apply replacements to original bytes without reserializing Markdown.
body := bytes.TrimRight(expandLinkTabs(line), "\r\n")
width := len(body)
matched := 0
@@ -68,7 +68,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
body = body[1:]
}
} else if indent == len(body) {
body = nil // 空行不结束列表容器。
body = nil // Blank lines do not close list containers.
} else if indent >= container.indent {
body = body[container.indent:]
} else {
@@ -80,14 +80,14 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
rest := bytes.TrimLeft(body, " ")
if marker := listMarker.Find(rest); len(body)-len(rest) <= 3 && marker != nil {
mark := bytes.TrimRight(marker, " ")
// 同级列表的新条目结束上一项段落,不受“只能以 1 打断段落”的限制。
// A new sibling list item ends the previous item's paragraph without the "only 1 interrupts a paragraph" restriction.
if mark[len(mark)-1] == containers[matched].marker {
paragraph = false
}
}
containers = containers[:matched]
fence = nil // 容器结束时,其内部未闭合的代码块也结束。
// 段落允许省略引用或列表前缀的续行,不能把其缩进误认成代码。
fence = nil // Closing a container also closes its unclosed code block.
// Paragraphs allow lazy continuation without quote/list prefixes; do not mistake their indentation for code.
}
if fence != nil {
trimmed := bytes.TrimLeft(body, " ")
@@ -96,7 +96,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
}
continue
}
// 新的引用或列表可以嵌套;列表的后续行按其内容起始列剥离缩进。
// New quotes or lists may nest; strip continuation indentation using the list's content-start column.
for {
indent := len(body) - len(bytes.TrimLeft(body, " "))
if indent > 3 || indent == len(body) {
@@ -110,10 +110,10 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
body = body[1:]
}
} else if paragraphBoundary.Match(rest) {
break // 分隔线优先于列表标记。
break // Thematic breaks take precedence over list markers.
} else if marker := listMarker.Find(rest); marker != nil {
mark := bytes.TrimRight(marker, " ")
// 有序列表只有从 1 开始且非空时才能打断段落。
// Only nonempty ordered lists starting at 1 may interrupt a paragraph.
if paragraph && (len(marker) == len(rest) || (mark[0] >= '0' && mark[0] <= '9' && !bytes.Equal(mark[:len(mark)-1], []byte("1")))) {
break
}
@@ -134,7 +134,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
paragraph = false
continue
}
// 缩进代码不能打断已有段落,列表容器自身的缩进不计入这四列。
// Indented code cannot interrupt an existing paragraph; the list container's indentation does not count toward the four columns.
if len(body)-len(trimmed) >= 4 && !paragraph {
continue
}
@@ -144,7 +144,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
continue
}
paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed)
// 将剥离容器时的展开列数映射回原始字节,保留制表符和正文位置。
// Map expanded container columns back to original bytes, preserving tabs and text positions.
start, column := 0, 0
for column < width-len(body) {
if line[start] == '\t' {
@@ -183,7 +183,7 @@ func expandLinkTabs(line []byte) []byte {
return out
}
// fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串
// fenceOpen recognizes a fenced-code opening (up to three leading spaces, at least three backticks or tildes) and returns its fence string.
func fenceOpen(line []byte) []byte {
indent := len(line) - len(bytes.TrimLeft(line, " "))
if indent > 3 {
@@ -203,7 +203,7 @@ func fenceOpen(line []byte) []byte {
return rest[:n]
}
// transformOutsideCode 只在行内代码 `…` 之外的片段上做转换。
// transformOutsideCode transforms only fragments outside inline code `...`.
func transformOutsideCode(line []byte, transform func(text []byte, offset int) []byte) []byte {
var out []byte
offset := 0
+4 -4
View File
@@ -32,7 +32,7 @@ type Metadata struct {
var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`)
// LocalTarget 在链接边界解码 Markdown 和 URL,拒绝远程地址、绝对路径及越界路径。
// LocalTarget decodes Markdown and URLs at the link boundary, rejecting remote URLs, absolute paths, and out-of-bounds paths.
func LocalTarget(note, raw string) (string, *url.URL, bool) {
raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1"))
u, err := url.Parse(raw)
@@ -46,7 +46,7 @@ func LocalTarget(note, raw string) (string, *url.URL, bool) {
return target, u, true
}
// Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。
// Analyze extracts semantic relationships without reserializing text; source positions remain original line numbers.
func Analyze(note string, content []byte) Metadata {
m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
addWarning := func(key string, args ...any) {
@@ -138,7 +138,7 @@ func Analyze(note string, content []byte) Metadata {
return ast.WalkSkipChildren, nil
case *ast.Link:
addRef(node.Destination.Str(source), node.Pos())
// 链接文字中可以嵌套图片,仍须记录其资源引用。
// Link labels may contain nested images whose resource references must still be recorded.
case *ast.Image:
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
@@ -156,7 +156,7 @@ func Analyze(note string, content []byte) Metadata {
}
return ast.WalkContinue, nil
})
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
// Historically imported notes may reference HTML resources; cleanup must include them.
excluded := nonProseRanges(root)
for _, span := range importLinkSpans(content) {
if !span.html {
+3 -3
View File
@@ -12,8 +12,8 @@ import (
"github.com/yuin/goldmark/v2/util"
)
// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。
// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。
// Relationship extraction only: math and code are not body text, so #1 and example links must not become knowledge relationships.
// Delimiter rules match frontend Typora extensions; this parser does not render Markdown.
type metadataMath struct{}
func (metadataMath) Trigger() []byte { return []byte{'$'} }
@@ -79,7 +79,7 @@ func parseMetadata(source []byte) ast.Node {
return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source)
}
// 返回非正文的字节范围,供无损链接改写器跳过公式中的示例语法。
// Return non-body byte ranges so lossless link rewriting can skip example syntax inside math.
func nonProseRanges(root ast.Node) [][2]int {
var ranges [][2]int
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+3 -3
View File
@@ -16,8 +16,8 @@ type imageTransfer struct {
removed bool
}
// 调用方持有写锁,并在后续笔记移动失败时撤回新副本。
// 旧的独立附件仍由 Move 连同目录移动;共享图片只在其他笔记不再引用时移走原件。
// The caller holds the write lock and removes new copies if the subsequent note move fails.
// Move still carries legacy dedicated attachments with their directory; remove shared originals only when no other note references them.
func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targets map[string]string, err error) {
content, _, err := v.Read(from)
if err != nil {
@@ -68,7 +68,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ
}
info, e := os.Lstat(source)
if os.IsNotExist(e) {
continue // 已缺失的图片保留链接,由缺失检查报告。
continue // Retain links to already-missing images for the missing-resource report.
}
if e != nil {
return transfers, targets, e
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"time"
)
// 调用方持有写锁并已读取验证元数据。先删除内容,失败时保留条目的管理依据。
// The caller holds the write lock and has validated metadata. Delete contents first, retaining management metadata on failure.
func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
dir := filepath.Join(v.trash, metadata.ID)
entries, err := os.ReadDir(dir)
@@ -29,7 +29,7 @@ func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
return err
}
if err := os.Remove(dir); err != nil {
// 父目录不可写等原因仍可能阻止最后的 rmdir,恢复元数据以便继续清理。
// A non-writable parent or similar error can still block the final rmdir; restore metadata to allow later cleanup.
data, _ := json.Marshal(metadata)
return errors.Join(err, atomicWrite(metaPath, data))
}
@@ -47,8 +47,8 @@ type TrashPurgeResult struct {
Failed []TrashFailure `json:"failed"`
}
// PurgeTrash 在与恢复相同的写锁内读取元数据并清理。days=0 清空所有;
// 自动清理的“永不删除”由调用方处理,不调用本方法。
// PurgeTrash reads metadata and cleans entries under the same write lock as restore. days=0 empties everything;
// the caller handles automatic cleanup's "never delete" setting by not invoking this method.
func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -63,7 +63,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
for _, dir := range dirs {
id := dir.Name()
if !trashIDPattern.MatchString(id) {
continue // 不删除非本程序管理的文件。
continue // Do not delete files not managed by this application.
}
metadata, err := v.readTrashMetadata(id)
if err == nil && days > 0 && (metadata.DeletedAt > now.UnixMilli() || (now.UnixMilli()-metadata.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) {
+1 -1
View File
@@ -143,7 +143,7 @@ func TestTrashRetentionAndInvalidMetadata(t *testing.T) {
t.Fatalf("不应删除未过期/无效条目: %v", err)
}
}
// 极大天数不应溢出成过期期限。
// Very large day counts must not overflow into an expired retention period.
result, err = v.PurgeTrash(1<<53-1, now)
if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 {
t.Fatalf("极大期限: %+v, %v", result, err)
+3 -3
View File
@@ -12,7 +12,7 @@ import (
"unicode/utf8"
)
// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。
// TreeIcon is a file-tree display attribute and does not affect Markdown contents or revisions.
type TreeIcon struct {
Kind string `json:"kind"`
Value string `json:"value"`
@@ -105,7 +105,7 @@ func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error {
return v.writeTreeIcons(icons)
}
// 从所属子树移出属性,供移动和回收站操作在原有写锁内提交。
// Extract attributes from the owning subtree for move/recycle-bin operations to commit under the existing write lock.
func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon {
taken := map[string]TreeIcon{}
for p, icon := range icons {
@@ -117,7 +117,7 @@ func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[s
return taken
}
// 回收站保留属性,但普通回收站 API 不暴露内部元数据。
// The recycle bin retains attributes, but ordinary recycle-bin APIs do not expose internal metadata.
type trashMetadata struct {
TrashItem
Icons map[string]TreeIcon `json:"icons,omitempty"`
+1 -1
View File
@@ -90,7 +90,7 @@ func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) {
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
t.Fatalf("恢复默认失败: %v", got)
}
// 原有无图标的回收站条目也可恢复。
// Legacy recycle-bin entries without icons remain restorable.
item, err = v.Delete("replacement")
if err != nil {
t.Fatal(err)
+37 -37
View File
@@ -1,4 +1,4 @@
// Package vault 管理笔记仓库目录:文件即真源,所有路径以仓库根为基准、使用 "/" 分隔。
// Package vault manages the note directory: files are the source of truth, and all paths are vault-relative with "/" separators.
package vault
import (
@@ -30,7 +30,7 @@ var (
ErrTooLarge = i18n.New("file_is_too_large_5bfcab")
)
// ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空)
// ConflictError indicates a failed If-Match check; Rev is the current server revision (empty if the file does not exist).
type ConflictError struct{ Rev string }
func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() }
@@ -43,9 +43,9 @@ const (
)
type Vault struct {
root string // 已解析符号链接的绝对路径
root string // Absolute path with symlinks resolved.
trash string
// 串行化所有写操作,保证"检查版本 + 写入"、移动等复合操作的原子性
// Serialize writes to make compound operations such as revision-check/write and moves atomic.
mu sync.Mutex
}
@@ -53,7 +53,7 @@ type Entry struct {
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // 毫秒
ModTime int64 `json:"mtime"` // Milliseconds.
Info fs.FileInfo `json:"-"`
Icon *TreeIcon `json:"icon,omitempty"`
}
@@ -62,8 +62,8 @@ type TrashItem struct {
ID string `json:"id"`
Path string `json:"path"`
Dir bool `json:"dir"`
DeletedAt int64 `json:"deletedAt"` // 毫秒
// 随笔记一起删除的 .assets 目录名(相对回收站条目目录)
DeletedAt int64 `json:"deletedAt"` // Milliseconds.
// .assets directory deleted with the note, relative to the recycle-bin entry directory.
Assets string `json:"assets,omitempty"`
}
@@ -88,14 +88,14 @@ func Open(root string) (*Vault, error) {
func (v *Vault) Root() string { return v.root }
// Rev 为内容版本号:sha256 前 8 字节的十六进制
// Rev is the content revision: the first eight SHA-256 bytes in hexadecimal.
func Rev(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:8])
}
// CleanPath 校验客户端传入的相对路径:不允许绝对路径、空段、"."/".." 段以及以 "." 开头的隐藏段
// (隐藏段包括 .rawmark 元数据目录与 .git 等,一律不经 API 暴露)
// CleanPath validates client-relative paths: reject absolute paths, empty segments, "."/"..", and hidden segments starting with ".".
// Hidden segments include .rawmark metadata and .git; none are exposed through the API.
func CleanPath(p string) (string, error) {
if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") {
return "", ErrInvalidPath
@@ -110,7 +110,7 @@ func CleanPath(p string) (string, error) {
func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) }
// AssetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets
// AssetsDir returns the note's Typora-style image directory: a.md -> a.assets.
func AssetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt }
func (v *Vault) abs(rel string) (string, error) {
@@ -125,7 +125,7 @@ func (v *Vault) abs(rel string) (string, error) {
return p, nil
}
// checkInside 确认路径最深的已存在祖先在解析符号链接后仍位于仓库内,防止经符号链接逃逸
// checkInside resolves the deepest existing ancestor and ensures it stays inside the vault, preventing symlink escapes.
func (v *Vault) checkInside(p string) error {
for cur := p; ; {
real, err := filepath.EvalSymlinks(cur)
@@ -146,7 +146,7 @@ func (v *Vault) checkInside(p string) error {
}
}
// ---- 读写 ----
// ---- Reading and writing ----
func (v *Vault) Read(rel string) ([]byte, string, error) {
if !IsNote(rel) {
@@ -166,7 +166,7 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
return data, Rev(data), nil
}
// Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound
// Stat returns regular-file or directory information, or ErrNotFound if missing.
func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
p, err := v.abs(rel)
if err != nil {
@@ -179,7 +179,7 @@ func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
return info, err
}
// Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖
// Precondition maps HTTP conditions: IfMatch requires the current revision; IfNoneMatch requires a new file; omitting both overwrites unconditionally.
type Precondition struct {
IfMatch string
IfNoneMatch bool
@@ -220,7 +220,7 @@ func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, err
return Rev(content), nil
}
// atomicWrite 先写同目录临时文件并 fsync,再 rename 覆盖,保证不会留下写了一半的文件
// atomicWrite writes and fsyncs a sibling temporary file before renaming it over the destination, preventing partial files.
func atomicWrite(p string, data []byte) error {
dir := filepath.Dir(p)
f, err := os.CreateTemp(dir, tmpPrefix+"*")
@@ -228,7 +228,7 @@ func atomicWrite(p string, data []byte) error {
return err
}
tmp := f.Name()
defer os.Remove(tmp) // rename 成功后为空操作
defer os.Remove(tmp) // No-op after a successful rename.
if _, err := f.Write(data); err != nil {
f.Close()
return err
@@ -266,7 +266,7 @@ func (v *Vault) Mkdir(rel string) error {
return os.MkdirAll(p, 0o755)
}
// OpenRaw 打开仓库内的普通文件(图片等资源),调用方负责关闭
// OpenRaw opens a regular vault file (such as an image); the caller must close it.
func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
p, err := v.abs(rel)
if err != nil {
@@ -290,9 +290,9 @@ func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
return f, info, nil
}
// ---- 列表 ----
// ---- Listing ----
// IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。
// IsImage recognizes extensions supported by image management; it does not validate file contents.
func IsImage(p string) bool {
switch strings.ToLower(path.Ext(p)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
@@ -302,7 +302,7 @@ func IsImage(p string) bool {
}
}
// Tree 列出文件夹、笔记和图片,沿用 TreeAt 的隐藏路径与符号链接边界。
// Tree lists folders, notes, and images within TreeAt's hidden-path and symlink boundaries.
func (v *Vault) Tree() ([]Entry, error) {
entries, err := v.TreeAt("")
if err != nil {
@@ -327,7 +327,7 @@ func (v *Vault) Tree() ([]Entry, error) {
return visible, nil
}
// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。
// TreeAt recursively lists regular files and subfolders relative to the selected directory. An empty path selects the vault root; hidden entries and symlinks are not exposed.
func (v *Vault) TreeAt(rel string) ([]Entry, error) {
root := v.root
if rel != "" {
@@ -371,10 +371,10 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
return entries, err
}
// ---- 移动 / 重命名 ----
// ---- Moving / renaming ----
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
// Move moves or renames a note, image, or folder and reports whether atomic rewriting changed the moved item's file identity.
// The note's .assets directory follows it; renaming also rewrites links to its own image directory.
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
src, err := v.abs(from)
if err != nil {
@@ -457,7 +457,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return false, err
}
}
// 笔记、目录和图片的移动都可能改变跨文件相对链接。
// Moving notes, folders, or images can change relative links between files.
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets)
if err != nil {
return false, err
@@ -517,7 +517,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return rewritten, err
}
// ---- 回收站 ----
// ---- Recycle bin ----
func newTrashID() string {
var b [4]byte
@@ -527,7 +527,7 @@ func newTrashID() string {
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
// Delete 把笔记(连同 .assets)、图片或文件夹移入回收站
// Delete moves a note (with .assets), image, or folder to the recycle bin.
func (v *Vault) Delete(rel string) (TrashItem, error) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -591,7 +591,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
rollbackFailed = true
}
}
// 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。
// Retain directories and metadata on rollback failure instead of deleting the last recovery information.
if !rollbackFailed {
if cleanup := os.RemoveAll(dir); cleanup != nil {
err = errors.Join(err, cleanup)
@@ -660,7 +660,7 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
for _, d := range dirs {
item, err := v.readTrashItem(d.Name())
if errors.Is(err, ErrInvalidPath) || errors.Is(err, ErrNotFound) {
continue // 非本程序创建的目录或删除未完成的条目
continue // A directory not created by this application, or an incomplete deletion entry.
}
if err != nil {
return nil, err
@@ -711,7 +711,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
return
}
if cleaning {
// 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。
// Cleanup may have deleted metadata; recreate recovery information before undoing this move.
if rollback := os.MkdirAll(dir, 0o755); rollback != nil {
err = errors.Join(err, rollback)
return
@@ -756,12 +756,12 @@ func (v *Vault) Purge(id string) error {
return v.purgeTrashItemLocked(metadata)
}
// ---- 资源 ----
// ---- Resources ----
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
// SaveAsset 把上传的文件存入笔记同级的 assets 目录,以内容哈希命名(相同内容只存一份),
// 返回可直接写入 Markdown 的相对链接
// SaveAsset stores an upload in the note's sibling assets directory, named by content hash (identical contents are stored once),
// and returns a relative link ready for Markdown.
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
if !IsNote(note) {
return "", ErrInvalidPath
@@ -772,7 +772,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
return v.saveAssetAt(path.Join(path.Dir(note), "assets"), origName, r, limit)
}
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。
// SaveFolderAsset uploads to the current folder's shared assets directory for image management, using hash deduplication.
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
if !IsImage(origName) {
return "", i18n.New("w_choose_an_image_file_544069", ErrInvalidPath)
@@ -822,7 +822,7 @@ func (v *Vault) saveAssetAt(relativeDir, origName string, r io.Reader, limit int
return "./" + EscapeLinkPath(path.Base(relativeDir)) + "/" + EscapeLinkPath(path.Base(target)), nil
}
// 管理界面允许替换图片,文件名不保证内容未变;只复用实际内容相同的普通文件。
// Image management permits replacement, so filenames do not guarantee unchanged contents; reuse only regular files with matching contents.
func (v *Vault) assetDestination(dir, name string, data []byte) (string, bool, error) {
base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
for i := 0; ; i++ {
@@ -879,7 +879,7 @@ func sniffExt(data []byte) string {
}
}
// EscapeLinkPath 只转义会破坏 Markdown 链接目标的字符,中文等保持原样(与 Typora 写法一致)
// EscapeLinkPath escapes only characters that break Markdown link destinations, preserving Unicode such as Chinese to match Typora.
func EscapeLinkPath(s string) string {
return linkEscaper.Replace(s)
}
+2 -2
View File
@@ -18,7 +18,7 @@ func newVault(t *testing.T) *Vault {
return v
}
// 显式构造旧版独立附件,覆盖升级后的移动与回收站恢复。
// Construct legacy dedicated attachments explicitly to cover moves and recycle-bin restores after upgrading.
func legacyAsset(t *testing.T, v *Vault, note, name, content string) string {
t.Helper()
dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note)))
@@ -85,7 +85,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
t.Fatal(err)
}
legacyAsset(t, v, "n.md", "x.png", "image")
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
// The note name fits, but its derived .assets name exceeds the filesystem's per-segment length limit.
destination := strings.Repeat("a", 250) + ".md"
if _, err := v.Move("n.md", destination); err == nil {
t.Fatal("图片目录移动应失败")
+13 -13
View File
@@ -1,5 +1,5 @@
// Package watch 监听仓库目录的变化(包括 RawMark 自身的写入和外部编辑器/同步工具的修改),
// 与上一次快照比较后把差异作为事件广播。所有变更都经由文件系统这一条路径,客户端据 rev 识别自己的写入。
// Package watch monitors vault changes, including RawMark writes and external editor/sync-tool changes,
// and broadcasts differences from the previous snapshot. All changes flow through the filesystem; clients recognize their own writes by rev.
package watch
import (
@@ -22,7 +22,7 @@ import (
const (
debounce = 100 * time.Millisecond
// inotify 在 Docker Desktop 的绑定挂载、NFS 等环境下不可靠,定期全量扫描兜底
// inotify is unreliable on Docker Desktop bind mounts, NFS, and similar setups; periodically perform a full scan.
rescanInterval = 5 * time.Minute
)
@@ -43,8 +43,8 @@ type Watcher struct {
onScan func(notes map[string]string)
}
// Start 开始监听。onScan 在首次扫描及之后每次有变化的扫描后调用,参数为全部笔记的路径 -> rev(用于更新搜索索引)
// 返回退出通知,实例恢复须等文件监听停止后再替换笔记库。
// Start begins watching. onScan receives all note paths -> rev after the first scan and each changed scan, for search indexing.
// Return a shutdown notification; instance recovery must wait for watching to stop before replacing the vault.
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) (<-chan struct{}, error) {
fw, err := fsnotify.NewWatcher()
if err != nil {
@@ -82,7 +82,7 @@ func (w *Watcher) loop(ctx context.Context) {
if !ok {
return
}
// 事件队列溢出等情况会丢事件,立即全量扫描补齐
// Queue overflows can lose events; immediately rescan to recover them.
slog.Warn("文件监听出错,执行全量扫描", "err", err)
timer.Reset(debounce)
case <-timer.C:
@@ -126,7 +126,7 @@ func notes(snap map[string]fileState) map[string]string {
return out
}
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
// scan reads the current tree and recalculates revisions only for new files or changes in size/modification time.
func (w *Watcher) scan() (map[string]fileState, error) {
entries, err := w.v.TreeAt("")
if err != nil {
@@ -145,7 +145,7 @@ func (w *Watcher) scan() (map[string]fileState, error) {
} else {
data, err := os.ReadFile(abs)
if errors.Is(err, fs.ErrNotExist) {
continue // 扫描期间被删除,下一轮会处理
continue // Deleted during scanning; the next pass will handle it.
}
if err != nil {
return nil, i18n.New("could_not_read_note_s_708ab5", e.Path, err)
@@ -166,13 +166,13 @@ func (w *Watcher) scan() (map[string]fileState, error) {
}
for d := range w.watched {
if !dirs[d] {
delete(w.watched, d) // 目录已删除,inotify 会自动移除监听
delete(w.watched, d) // The directory was deleted; inotify removes its watch automatically.
}
}
return next, nil
}
// diff 按文件系统身份识别移动,内容相同不代表同一个文件。
// diff identifies moves by filesystem identity; identical contents do not imply the same file.
func diff(prev, next map[string]fileState) []events.Event {
var evs []events.Event
structural := false
@@ -184,7 +184,7 @@ func diff(prev, next map[string]fileState) []events.Event {
structural = true
removed[p] = old
}
candidates := map[string]string{} // 新路径 -> 唯一的原路径
candidates := map[string]string{} // New path -> unique original path.
destinations := map[string]int{}
for p, cur := range next {
old, existed := prev[p]
@@ -206,7 +206,7 @@ func diff(prev, next map[string]fileState) []events.Event {
case old.dir != cur.dir:
structural = true
case !cur.dir && cur.rev == "" && (old.size != cur.size || old.mtime != cur.mtime || !os.SameFile(old.info, cur.info)):
// 图片没有正文版本号;替换后仍需刷新列表元数据与预览。
// Images lack text revisions; replacements must still refresh list metadata and previews.
if vault.IsImage(p) {
structural = true
}
@@ -229,7 +229,7 @@ func diff(prev, next map[string]fileState) []events.Event {
move.From = parent.Path + strings.TrimPrefix(move.From, parent.From)
}
}
// 先应用父目录映射;子项另有改名时,从映射后的路径继续移动。
// Apply parent-directory mappings first; independently renamed children then move from their mapped paths.
if move.From != move.Path {
emitted = append(emitted, move)
}