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

+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("图片目录移动应失败")