chore: standardize project comments in English
This commit is contained in:
1 parent
a874a39ea5
commit
ccb16acbea
167 files changed
+820
-820
No files matched your search
@@ -12,7 +12,7 @@ type CleanupSkip struct {
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
|
||||
// CleanupImages rescans references under the write lock; the derived index never authorizes deletion.
|
||||
func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
|
||||
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
|
||||
v.mu.Lock()
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。
|
||||
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。
|
||||
// Export builds an export in a temporary file; the caller must close and remove it after success.
|
||||
// ZIP entries retain vault-relative paths and unchanged text. Export only source files; do not parse or download external resources.
|
||||
func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) {
|
||||
if len(paths) == 0 {
|
||||
return nil, "", i18n.New("w_choose_content_to_export_170d5e", ErrInvalidPath)
|
||||
@@ -46,7 +46,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
|
||||
files[path.Join(p, entry.Path)] = entry.Dir
|
||||
}
|
||||
}
|
||||
// 仅收集已选笔记的附件,不追踪链接到的其他笔记。
|
||||
// Collect attachments only for selected notes, without following links to other notes.
|
||||
notes := make([]string, 0)
|
||||
for p, dir := range files {
|
||||
if !dir && IsNote(p) {
|
||||
@@ -165,7 +165,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
|
||||
return tmp, name, nil
|
||||
}
|
||||
|
||||
// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。
|
||||
// Attachments may use ../ to reference other folders, but cannot expose hidden paths or escape scope through symlinks.
|
||||
func (v *Vault) exportPath(rel string) error {
|
||||
full, err := v.abs(rel)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,7 +17,7 @@ type MissingAsset struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// exclude 由应用层提供,笔记库不持有数据库和资源目录的配置。
|
||||
// exclude comes from the application layer; the vault does not own database or resource-directory configuration.
|
||||
func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (result *os.File, err error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
@@ -100,7 +100,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
|
||||
return e
|
||||
}
|
||||
for _, target := range Analyze(rel, content).Assets {
|
||||
// Analyze 的目标已解析为仓库相对路径,仅检查本地资源。
|
||||
// Analyze resolves targets to vault-relative paths; inspect only local resources.
|
||||
if strings.HasPrefix(target.Target, "/") || strings.Contains(target.Target, "://") {
|
||||
continue
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (res
|
||||
return nil, err
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
// 报告放在内部目录,不与用户的普通 Markdown 文件重名;不修改原链接。
|
||||
// Keep the report in an internal directory to avoid collisions with user Markdown files; preserve original links.
|
||||
out, e := z.Create(path.Join(".rawmark-export", "missing-assets.json"))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。
|
||||
// ReplaceImage replaces an existing image in place, preserving reference paths; it cannot create files or change formats.
|
||||
func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error {
|
||||
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
|
||||
return i18n.New("w_choose_a_file_with_8410e9", ErrInvalidPath)
|
||||
@@ -52,7 +52,7 @@ type linkChange struct {
|
||||
before, after []byte
|
||||
}
|
||||
|
||||
// 调用方持有写锁;先计算全部改写,移动成功后再写入,出错时保留回滚依据。
|
||||
// The caller holds the write lock. Compute all rewrites before moving, write them after success, and retain rollback information on error.
|
||||
func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool, images map[string]string) ([]linkChange, error) {
|
||||
entries, err := v.Tree()
|
||||
if err != nil {
|
||||
@@ -103,7 +103,7 @@ func movedPath(p, from, to string, directory bool) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// 只替换链接地址,不重新序列化正文;移动和图片归位共用相同的代码/公式边界。
|
||||
// Replace only link destinations without reserializing text; moves and image relocation share code/math boundaries.
|
||||
func rewriteLocalLinks(content []byte, oldNote, newNote string, mapTarget func(string) string) ([]byte, error) {
|
||||
var out []byte
|
||||
last := 0
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
|
||||
var ErrInvalidImport = i18n.New("invalid_import_contents_ed556a")
|
||||
|
||||
// ImportFile 的读取范围是单个上传文件,ZIP 与普通 Markdown 共用这一输入边界。
|
||||
// ImportFile bounds reads to one uploaded file; ZIP and plain Markdown share this input boundary.
|
||||
type ImportFile struct {
|
||||
Name string
|
||||
Reader io.ReaderAt
|
||||
@@ -43,7 +43,7 @@ type ImportResult struct {
|
||||
WarningMessages []*i18n.Error `json:"-"`
|
||||
}
|
||||
|
||||
// Import 在隐藏目录内整理完整内容,提交前统一检查冲突,绝不覆盖已有文件。
|
||||
// Import prepares complete contents in a hidden directory and checks all conflicts before commit, never overwriting existing files.
|
||||
func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFile) (result ImportResult, err error) {
|
||||
result = ImportResult{Paths: []string{}, Warnings: []string{}}
|
||||
if len(files) == 0 || len(files) > maxImportEntries {
|
||||
@@ -82,7 +82,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
entries := map[string]struct{}{}
|
||||
var total int64
|
||||
var notes []string
|
||||
// 每个来源文件只写一次;目录与文件重名也会在暂存阶段报错。
|
||||
// Write each source file once; file/directory name collisions also fail during staging.
|
||||
write := func(name string, isDir bool, reader io.Reader) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
@@ -140,7 +140,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
for _, f := range z.File {
|
||||
if f.Name == ".rawmark-export/missing-assets.json" {
|
||||
continue
|
||||
} // 本程序笔记导出的缺失附件报告,不作为笔记导入。
|
||||
} // Do not import RawMark's missing-attachment export report as a note.
|
||||
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
||||
return result, i18n.New("w_zip_cannot_contain_symlinks_5f596f", ErrInvalidImport)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
// 所有笔记改链完成后再清理已迁移的原图,避免后处理的笔记读取不到共享来源。
|
||||
// Clean migrated source images only after rewriting all notes, so later notes can still read shared sources.
|
||||
if len(migrated) > 0 {
|
||||
used := map[string]bool{}
|
||||
for _, note := range notes {
|
||||
@@ -232,7 +232,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
}
|
||||
}
|
||||
result.Notes = len(notes)
|
||||
// 附件数量为最终保留的文件,包含目录共享图片与其他原始附件。
|
||||
// The attachment count covers final retained files, including folder-shared images and other original attachments.
|
||||
count := 0
|
||||
err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -296,7 +296,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
return result, err
|
||||
}
|
||||
if zipped {
|
||||
// 暂存目录与仓库位于同一文件系统,整个 ZIP 一次 rename 发布。
|
||||
// Staging and the vault share a filesystem; publish the entire ZIP import with one rename.
|
||||
err = os.Rename(stage, filepath.Join(parent, folder))
|
||||
} else {
|
||||
var published []string
|
||||
|
||||
@@ -23,7 +23,7 @@ type importLink struct {
|
||||
html bool
|
||||
}
|
||||
|
||||
// 链接中的空白允许一次换行,但不能跨过段落。
|
||||
// Whitespace in links may include one newline but cannot cross paragraphs.
|
||||
func linkSpace(text []byte, pos int) int {
|
||||
newline := false
|
||||
for pos < len(text) {
|
||||
@@ -42,7 +42,7 @@ func linkSpace(text []byte, pos int) int {
|
||||
return pos
|
||||
}
|
||||
|
||||
// linkDestination 返回地址区间和地址结束后的下标;地址本身不含尖括号。
|
||||
// linkDestination returns the destination range and the following index; angle brackets are excluded from the destination.
|
||||
func linkDestination(text []byte, pos int) (start, end, next int) {
|
||||
start = linkSpace(text, pos)
|
||||
if start == len(text) {
|
||||
@@ -105,7 +105,7 @@ func linkTitleEnd(text []byte, pos int) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。
|
||||
// importLinkSpans serves import, export, and image renaming, returning original byte positions without reserializing text.
|
||||
func importLinkSpans(content []byte) []importLink {
|
||||
var text []byte
|
||||
var source []int
|
||||
@@ -123,7 +123,7 @@ func importLinkSpans(content []byte) []importLink {
|
||||
lastEnd = offset + len(line)
|
||||
return line
|
||||
})
|
||||
// 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。
|
||||
// Inline code can span lines. Mask its markers while preserving bracket structure around link labels.
|
||||
masked := bytes.Repeat([]byte(" "), len(text))
|
||||
pos := 0
|
||||
for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) {
|
||||
@@ -171,7 +171,7 @@ func importLinkSpans(content []byte) []importLink {
|
||||
}
|
||||
add(start, end, false)
|
||||
definitionEnd = next
|
||||
// 引用定义及其标题不是行内正文,避免再次扫描标题中的示例标记。
|
||||
// Reference definitions and their titles are not inline body text; avoid rescanning example markers in titles.
|
||||
for i := m[0]; i < next; i++ {
|
||||
if text[i] != '\n' && text[i] != '\r' {
|
||||
text[i] = ' '
|
||||
@@ -214,7 +214,7 @@ func importLinkSpans(content []byte) []importLink {
|
||||
add(start, end, false)
|
||||
i = close
|
||||
if open == 0 || text[open-1] != '!' {
|
||||
brackets = nil // 链接不能嵌套,图片可以位于链接标签内。
|
||||
brackets = nil // Links cannot nest, but images can appear inside link labels.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。
|
||||
// Count only newly generated copies; existing attachments with the same name must match the hash-derived contents.
|
||||
dir := path.Join(path.Dir(note), "assets")
|
||||
dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data)
|
||||
if err != nil {
|
||||
|
||||
+16
-16
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
|
||||
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。
|
||||
// RewriteAssetLinks updates links from the old image directory (oldBase.assets/) after renaming a note.
|
||||
// Handle inline links/images `](...)`, reference definitions `[x]: ...`, and HTML `src="..."`, skipping code and math.
|
||||
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
|
||||
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
|
||||
oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/")
|
||||
@@ -43,17 +43,17 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
|
||||
})
|
||||
}
|
||||
|
||||
// transformMarkdownLines 复用链接改写的容器与代码块识别,避免导入时修改代码示例。
|
||||
// transformMarkdownLines reuses container/code-block detection from link rewriting to preserve code examples during import.
|
||||
func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform func(line, body []byte, offset int) []byte) []byte {
|
||||
lines := bytes.SplitAfter(content, []byte("\n"))
|
||||
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
|
||||
var fence []byte // Current code fence string; nil when outside a fenced block.
|
||||
var containers []linkContainer
|
||||
paragraph := false
|
||||
lineOffset := 0
|
||||
for i, line := range lines {
|
||||
offset := lineOffset
|
||||
lineOffset += len(line)
|
||||
// 分类时展开制表符,替换仍作用于原始字节,不重新序列化 Markdown。
|
||||
// Expand tabs for classification, but apply replacements to original bytes without reserializing Markdown.
|
||||
body := bytes.TrimRight(expandLinkTabs(line), "\r\n")
|
||||
width := len(body)
|
||||
matched := 0
|
||||
@@ -68,7 +68,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
|
||||
body = body[1:]
|
||||
}
|
||||
} else if indent == len(body) {
|
||||
body = nil // 空行不结束列表容器。
|
||||
body = nil // Blank lines do not close list containers.
|
||||
} else if indent >= container.indent {
|
||||
body = body[container.indent:]
|
||||
} else {
|
||||
@@ -80,14 +80,14 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
|
||||
rest := bytes.TrimLeft(body, " ")
|
||||
if marker := listMarker.Find(rest); len(body)-len(rest) <= 3 && marker != nil {
|
||||
mark := bytes.TrimRight(marker, " ")
|
||||
// 同级列表的新条目结束上一项段落,不受“只能以 1 打断段落”的限制。
|
||||
// A new sibling list item ends the previous item's paragraph without the "only 1 interrupts a paragraph" restriction.
|
||||
if mark[len(mark)-1] == containers[matched].marker {
|
||||
paragraph = false
|
||||
}
|
||||
}
|
||||
containers = containers[:matched]
|
||||
fence = nil // 容器结束时,其内部未闭合的代码块也结束。
|
||||
// 段落允许省略引用或列表前缀的续行,不能把其缩进误认成代码。
|
||||
fence = nil // Closing a container also closes its unclosed code block.
|
||||
// Paragraphs allow lazy continuation without quote/list prefixes; do not mistake their indentation for code.
|
||||
}
|
||||
if fence != nil {
|
||||
trimmed := bytes.TrimLeft(body, " ")
|
||||
@@ -96,7 +96,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 新的引用或列表可以嵌套;列表的后续行按其内容起始列剥离缩进。
|
||||
// New quotes or lists may nest; strip continuation indentation using the list's content-start column.
|
||||
for {
|
||||
indent := len(body) - len(bytes.TrimLeft(body, " "))
|
||||
if indent > 3 || indent == len(body) {
|
||||
@@ -110,10 +110,10 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
|
||||
body = body[1:]
|
||||
}
|
||||
} else if paragraphBoundary.Match(rest) {
|
||||
break // 分隔线优先于列表标记。
|
||||
break // Thematic breaks take precedence over list markers.
|
||||
} else if marker := listMarker.Find(rest); marker != nil {
|
||||
mark := bytes.TrimRight(marker, " ")
|
||||
// 有序列表只有从 1 开始且非空时才能打断段落。
|
||||
// Only nonempty ordered lists starting at 1 may interrupt a paragraph.
|
||||
if paragraph && (len(marker) == len(rest) || (mark[0] >= '0' && mark[0] <= '9' && !bytes.Equal(mark[:len(mark)-1], []byte("1")))) {
|
||||
break
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
|
||||
paragraph = false
|
||||
continue
|
||||
}
|
||||
// 缩进代码不能打断已有段落,列表容器自身的缩进不计入这四列。
|
||||
// Indented code cannot interrupt an existing paragraph; the list container's indentation does not count toward the four columns.
|
||||
if len(body)-len(trimmed) >= 4 && !paragraph {
|
||||
continue
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
|
||||
continue
|
||||
}
|
||||
paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed)
|
||||
// 将剥离容器时的展开列数映射回原始字节,保留制表符和正文位置。
|
||||
// Map expanded container columns back to original bytes, preserving tabs and text positions.
|
||||
start, column := 0, 0
|
||||
for column < width-len(body) {
|
||||
if line[start] == '\t' {
|
||||
@@ -183,7 +183,7 @@ func expandLinkTabs(line []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串
|
||||
// fenceOpen recognizes a fenced-code opening (up to three leading spaces, at least three backticks or tildes) and returns its fence string.
|
||||
func fenceOpen(line []byte) []byte {
|
||||
indent := len(line) - len(bytes.TrimLeft(line, " "))
|
||||
if indent > 3 {
|
||||
@@ -203,7 +203,7 @@ func fenceOpen(line []byte) []byte {
|
||||
return rest[:n]
|
||||
}
|
||||
|
||||
// transformOutsideCode 只在行内代码 `…` 之外的片段上做转换。
|
||||
// transformOutsideCode transforms only fragments outside inline code `...`.
|
||||
func transformOutsideCode(line []byte, transform func(text []byte, offset int) []byte) []byte {
|
||||
var out []byte
|
||||
offset := 0
|
||||
|
||||
@@ -32,7 +32,7 @@ type Metadata struct {
|
||||
|
||||
var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`)
|
||||
|
||||
// LocalTarget 在链接边界解码 Markdown 和 URL,拒绝远程地址、绝对路径及越界路径。
|
||||
// LocalTarget decodes Markdown and URLs at the link boundary, rejecting remote URLs, absolute paths, and out-of-bounds paths.
|
||||
func LocalTarget(note, raw string) (string, *url.URL, bool) {
|
||||
raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1"))
|
||||
u, err := url.Parse(raw)
|
||||
@@ -46,7 +46,7 @@ func LocalTarget(note, raw string) (string, *url.URL, bool) {
|
||||
return target, u, true
|
||||
}
|
||||
|
||||
// Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。
|
||||
// Analyze extracts semantic relationships without reserializing text; source positions remain original line numbers.
|
||||
func Analyze(note string, content []byte) Metadata {
|
||||
m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
|
||||
addWarning := func(key string, args ...any) {
|
||||
@@ -138,7 +138,7 @@ func Analyze(note string, content []byte) Metadata {
|
||||
return ast.WalkSkipChildren, nil
|
||||
case *ast.Link:
|
||||
addRef(node.Destination.Str(source), node.Pos())
|
||||
// 链接文字中可以嵌套图片,仍须记录其资源引用。
|
||||
// Link labels may contain nested images whose resource references must still be recorded.
|
||||
case *ast.Image:
|
||||
addRef(node.Destination.Str(source), node.Pos())
|
||||
return ast.WalkSkipChildren, nil
|
||||
@@ -156,7 +156,7 @@ func Analyze(note string, content []byte) Metadata {
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
|
||||
// Historically imported notes may reference HTML resources; cleanup must include them.
|
||||
excluded := nonProseRanges(root)
|
||||
for _, span := range importLinkSpans(content) {
|
||||
if !span.html {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/yuin/goldmark/v2/util"
|
||||
)
|
||||
|
||||
// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。
|
||||
// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。
|
||||
// Relationship extraction only: math and code are not body text, so #1 and example links must not become knowledge relationships.
|
||||
// Delimiter rules match frontend Typora extensions; this parser does not render Markdown.
|
||||
type metadataMath struct{}
|
||||
|
||||
func (metadataMath) Trigger() []byte { return []byte{'$'} }
|
||||
@@ -79,7 +79,7 @@ func parseMetadata(source []byte) ast.Node {
|
||||
return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source)
|
||||
}
|
||||
|
||||
// 返回非正文的字节范围,供无损链接改写器跳过公式中的示例语法。
|
||||
// Return non-body byte ranges so lossless link rewriting can skip example syntax inside math.
|
||||
func nonProseRanges(root ast.Node) [][2]int {
|
||||
var ranges [][2]int
|
||||
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
|
||||
@@ -16,8 +16,8 @@ type imageTransfer struct {
|
||||
removed bool
|
||||
}
|
||||
|
||||
// 调用方持有写锁,并在后续笔记移动失败时撤回新副本。
|
||||
// 旧的独立附件仍由 Move 连同目录移动;共享图片只在其他笔记不再引用时移走原件。
|
||||
// The caller holds the write lock and removes new copies if the subsequent note move fails.
|
||||
// Move still carries legacy dedicated attachments with their directory; remove shared originals only when no other note references them.
|
||||
func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targets map[string]string, err error) {
|
||||
content, _, err := v.Read(from)
|
||||
if err != nil {
|
||||
@@ -68,7 +68,7 @@ func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targ
|
||||
}
|
||||
info, e := os.Lstat(source)
|
||||
if os.IsNotExist(e) {
|
||||
continue // 已缺失的图片保留链接,由缺失检查报告。
|
||||
continue // Retain links to already-missing images for the missing-resource report.
|
||||
}
|
||||
if e != nil {
|
||||
return transfers, targets, e
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 调用方持有写锁并已读取验证元数据。先删除内容,失败时保留条目的管理依据。
|
||||
// The caller holds the write lock and has validated metadata. Delete contents first, retaining management metadata on failure.
|
||||
func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
|
||||
dir := filepath.Join(v.trash, metadata.ID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
@@ -29,7 +29,7 @@ func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(dir); err != nil {
|
||||
// 父目录不可写等原因仍可能阻止最后的 rmdir,恢复元数据以便继续清理。
|
||||
// A non-writable parent or similar error can still block the final rmdir; restore metadata to allow later cleanup.
|
||||
data, _ := json.Marshal(metadata)
|
||||
return errors.Join(err, atomicWrite(metaPath, data))
|
||||
}
|
||||
@@ -47,8 +47,8 @@ type TrashPurgeResult struct {
|
||||
Failed []TrashFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// PurgeTrash 在与恢复相同的写锁内读取元数据并清理。days=0 清空所有;
|
||||
// 自动清理的“永不删除”由调用方处理,不调用本方法。
|
||||
// PurgeTrash reads metadata and cleans entries under the same write lock as restore. days=0 empties everything;
|
||||
// the caller handles automatic cleanup's "never delete" setting by not invoking this method.
|
||||
func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
@@ -63,7 +63,7 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
|
||||
for _, dir := range dirs {
|
||||
id := dir.Name()
|
||||
if !trashIDPattern.MatchString(id) {
|
||||
continue // 不删除非本程序管理的文件。
|
||||
continue // Do not delete files not managed by this application.
|
||||
}
|
||||
metadata, err := v.readTrashMetadata(id)
|
||||
if err == nil && days > 0 && (metadata.DeletedAt > now.UnixMilli() || (now.UnixMilli()-metadata.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) {
|
||||
|
||||
@@ -143,7 +143,7 @@ func TestTrashRetentionAndInvalidMetadata(t *testing.T) {
|
||||
t.Fatalf("不应删除未过期/无效条目: %v", err)
|
||||
}
|
||||
}
|
||||
// 极大天数不应溢出成过期期限。
|
||||
// Very large day counts must not overflow into an expired retention period.
|
||||
result, err = v.PurgeTrash(1<<53-1, now)
|
||||
if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 {
|
||||
t.Fatalf("极大期限: %+v, %v", result, err)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。
|
||||
// TreeIcon is a file-tree display attribute and does not affect Markdown contents or revisions.
|
||||
type TreeIcon struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
@@ -105,7 +105,7 @@ func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error {
|
||||
return v.writeTreeIcons(icons)
|
||||
}
|
||||
|
||||
// 从所属子树移出属性,供移动和回收站操作在原有写锁内提交。
|
||||
// Extract attributes from the owning subtree for move/recycle-bin operations to commit under the existing write lock.
|
||||
func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon {
|
||||
taken := map[string]TreeIcon{}
|
||||
for p, icon := range icons {
|
||||
@@ -117,7 +117,7 @@ func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[s
|
||||
return taken
|
||||
}
|
||||
|
||||
// 回收站保留属性,但普通回收站 API 不暴露内部元数据。
|
||||
// The recycle bin retains attributes, but ordinary recycle-bin APIs do not expose internal metadata.
|
||||
type trashMetadata struct {
|
||||
TrashItem
|
||||
Icons map[string]TreeIcon `json:"icons,omitempty"`
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) {
|
||||
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("恢复默认失败: %v", got)
|
||||
}
|
||||
// 原有无图标的回收站条目也可恢复。
|
||||
// Legacy recycle-bin entries without icons remain restorable.
|
||||
item, err = v.Delete("replacement")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+37
-37
@@ -1,4 +1,4 @@
|
||||
// Package vault 管理笔记仓库目录:文件即真源,所有路径以仓库根为基准、使用 "/" 分隔。
|
||||
// Package vault manages the note directory: files are the source of truth, and all paths are vault-relative with "/" separators.
|
||||
package vault
|
||||
|
||||
import (
|
||||
@@ -30,7 +30,7 @@ var (
|
||||
ErrTooLarge = i18n.New("file_is_too_large_5bfcab")
|
||||
)
|
||||
|
||||
// ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空)
|
||||
// ConflictError indicates a failed If-Match check; Rev is the current server revision (empty if the file does not exist).
|
||||
type ConflictError struct{ Rev string }
|
||||
|
||||
func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() }
|
||||
@@ -43,9 +43,9 @@ const (
|
||||
)
|
||||
|
||||
type Vault struct {
|
||||
root string // 已解析符号链接的绝对路径
|
||||
root string // Absolute path with symlinks resolved.
|
||||
trash string
|
||||
// 串行化所有写操作,保证"检查版本 + 写入"、移动等复合操作的原子性
|
||||
// Serialize writes to make compound operations such as revision-check/write and moves atomic.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ type Entry struct {
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"mtime"` // 毫秒
|
||||
ModTime int64 `json:"mtime"` // Milliseconds.
|
||||
Info fs.FileInfo `json:"-"`
|
||||
Icon *TreeIcon `json:"icon,omitempty"`
|
||||
}
|
||||
@@ -62,8 +62,8 @@ type TrashItem struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
DeletedAt int64 `json:"deletedAt"` // 毫秒
|
||||
// 随笔记一起删除的 .assets 目录名(相对回收站条目目录)
|
||||
DeletedAt int64 `json:"deletedAt"` // Milliseconds.
|
||||
// .assets directory deleted with the note, relative to the recycle-bin entry directory.
|
||||
Assets string `json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ func Open(root string) (*Vault, error) {
|
||||
|
||||
func (v *Vault) Root() string { return v.root }
|
||||
|
||||
// Rev 为内容版本号:sha256 前 8 字节的十六进制
|
||||
// Rev is the content revision: the first eight SHA-256 bytes in hexadecimal.
|
||||
func Rev(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// CleanPath 校验客户端传入的相对路径:不允许绝对路径、空段、"."/".." 段以及以 "." 开头的隐藏段
|
||||
// (隐藏段包括 .rawmark 元数据目录与 .git 等,一律不经 API 暴露)
|
||||
// CleanPath validates client-relative paths: reject absolute paths, empty segments, "."/"..", and hidden segments starting with ".".
|
||||
// Hidden segments include .rawmark metadata and .git; none are exposed through the API.
|
||||
func CleanPath(p string) (string, error) {
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") {
|
||||
return "", ErrInvalidPath
|
||||
@@ -110,7 +110,7 @@ func CleanPath(p string) (string, error) {
|
||||
|
||||
func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) }
|
||||
|
||||
// AssetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets
|
||||
// AssetsDir returns the note's Typora-style image directory: a.md -> a.assets.
|
||||
func AssetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt }
|
||||
|
||||
func (v *Vault) abs(rel string) (string, error) {
|
||||
@@ -125,7 +125,7 @@ func (v *Vault) abs(rel string) (string, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// checkInside 确认路径最深的已存在祖先在解析符号链接后仍位于仓库内,防止经符号链接逃逸
|
||||
// checkInside resolves the deepest existing ancestor and ensures it stays inside the vault, preventing symlink escapes.
|
||||
func (v *Vault) checkInside(p string) error {
|
||||
for cur := p; ; {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
@@ -146,7 +146,7 @@ func (v *Vault) checkInside(p string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 读写 ----
|
||||
// ---- Reading and writing ----
|
||||
|
||||
func (v *Vault) Read(rel string) ([]byte, string, error) {
|
||||
if !IsNote(rel) {
|
||||
@@ -166,7 +166,7 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
|
||||
return data, Rev(data), nil
|
||||
}
|
||||
|
||||
// Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound
|
||||
// Stat returns regular-file or directory information, or ErrNotFound if missing.
|
||||
func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
@@ -179,7 +179,7 @@ func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖
|
||||
// Precondition maps HTTP conditions: IfMatch requires the current revision; IfNoneMatch requires a new file; omitting both overwrites unconditionally.
|
||||
type Precondition struct {
|
||||
IfMatch string
|
||||
IfNoneMatch bool
|
||||
@@ -220,7 +220,7 @@ func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, err
|
||||
return Rev(content), nil
|
||||
}
|
||||
|
||||
// atomicWrite 先写同目录临时文件并 fsync,再 rename 覆盖,保证不会留下写了一半的文件
|
||||
// atomicWrite writes and fsyncs a sibling temporary file before renaming it over the destination, preventing partial files.
|
||||
func atomicWrite(p string, data []byte) error {
|
||||
dir := filepath.Dir(p)
|
||||
f, err := os.CreateTemp(dir, tmpPrefix+"*")
|
||||
@@ -228,7 +228,7 @@ func atomicWrite(p string, data []byte) error {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // rename 成功后为空操作
|
||||
defer os.Remove(tmp) // No-op after a successful rename.
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
@@ -266,7 +266,7 @@ func (v *Vault) Mkdir(rel string) error {
|
||||
return os.MkdirAll(p, 0o755)
|
||||
}
|
||||
|
||||
// OpenRaw 打开仓库内的普通文件(图片等资源),调用方负责关闭
|
||||
// OpenRaw opens a regular vault file (such as an image); the caller must close it.
|
||||
func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
@@ -290,9 +290,9 @@ func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
|
||||
return f, info, nil
|
||||
}
|
||||
|
||||
// ---- 列表 ----
|
||||
// ---- Listing ----
|
||||
|
||||
// IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。
|
||||
// IsImage recognizes extensions supported by image management; it does not validate file contents.
|
||||
func IsImage(p string) bool {
|
||||
switch strings.ToLower(path.Ext(p)) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
|
||||
@@ -302,7 +302,7 @@ func IsImage(p string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Tree 列出文件夹、笔记和图片,沿用 TreeAt 的隐藏路径与符号链接边界。
|
||||
// Tree lists folders, notes, and images within TreeAt's hidden-path and symlink boundaries.
|
||||
func (v *Vault) Tree() ([]Entry, error) {
|
||||
entries, err := v.TreeAt("")
|
||||
if err != nil {
|
||||
@@ -327,7 +327,7 @@ func (v *Vault) Tree() ([]Entry, error) {
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。
|
||||
// TreeAt recursively lists regular files and subfolders relative to the selected directory. An empty path selects the vault root; hidden entries and symlinks are not exposed.
|
||||
func (v *Vault) TreeAt(rel string) ([]Entry, error) {
|
||||
root := v.root
|
||||
if rel != "" {
|
||||
@@ -371,10 +371,10 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// ---- 移动 / 重命名 ----
|
||||
// ---- Moving / renaming ----
|
||||
|
||||
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
|
||||
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
|
||||
// Move moves or renames a note, image, or folder and reports whether atomic rewriting changed the moved item's file identity.
|
||||
// The note's .assets directory follows it; renaming also rewrites links to its own image directory.
|
||||
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
src, err := v.abs(from)
|
||||
if err != nil {
|
||||
@@ -457,7 +457,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// 笔记、目录和图片的移动都可能改变跨文件相对链接。
|
||||
// Moving notes, folders, or images can change relative links between files.
|
||||
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -517,7 +517,7 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
// ---- 回收站 ----
|
||||
// ---- Recycle bin ----
|
||||
|
||||
func newTrashID() string {
|
||||
var b [4]byte
|
||||
@@ -527,7 +527,7 @@ func newTrashID() string {
|
||||
|
||||
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
|
||||
|
||||
// Delete 把笔记(连同 .assets)、图片或文件夹移入回收站
|
||||
// Delete moves a note (with .assets), image, or folder to the recycle bin.
|
||||
func (v *Vault) Delete(rel string) (TrashItem, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
@@ -591,7 +591,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
|
||||
rollbackFailed = true
|
||||
}
|
||||
}
|
||||
// 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。
|
||||
// Retain directories and metadata on rollback failure instead of deleting the last recovery information.
|
||||
if !rollbackFailed {
|
||||
if cleanup := os.RemoveAll(dir); cleanup != nil {
|
||||
err = errors.Join(err, cleanup)
|
||||
@@ -660,7 +660,7 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||
for _, d := range dirs {
|
||||
item, err := v.readTrashItem(d.Name())
|
||||
if errors.Is(err, ErrInvalidPath) || errors.Is(err, ErrNotFound) {
|
||||
continue // 非本程序创建的目录或删除未完成的条目
|
||||
continue // A directory not created by this application, or an incomplete deletion entry.
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -711,7 +711,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
return
|
||||
}
|
||||
if cleaning {
|
||||
// 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。
|
||||
// Cleanup may have deleted metadata; recreate recovery information before undoing this move.
|
||||
if rollback := os.MkdirAll(dir, 0o755); rollback != nil {
|
||||
err = errors.Join(err, rollback)
|
||||
return
|
||||
@@ -756,12 +756,12 @@ func (v *Vault) Purge(id string) error {
|
||||
return v.purgeTrashItemLocked(metadata)
|
||||
}
|
||||
|
||||
// ---- 资源 ----
|
||||
// ---- Resources ----
|
||||
|
||||
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
|
||||
|
||||
// SaveAsset 把上传的文件存入笔记同级的 assets 目录,以内容哈希命名(相同内容只存一份),
|
||||
// 返回可直接写入 Markdown 的相对链接
|
||||
// SaveAsset stores an upload in the note's sibling assets directory, named by content hash (identical contents are stored once),
|
||||
// and returns a relative link ready for Markdown.
|
||||
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
|
||||
if !IsNote(note) {
|
||||
return "", ErrInvalidPath
|
||||
@@ -772,7 +772,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
|
||||
return v.saveAssetAt(path.Join(path.Dir(note), "assets"), origName, r, limit)
|
||||
}
|
||||
|
||||
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。
|
||||
// SaveFolderAsset uploads to the current folder's shared assets directory for image management, using hash deduplication.
|
||||
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
|
||||
if !IsImage(origName) {
|
||||
return "", i18n.New("w_choose_an_image_file_544069", ErrInvalidPath)
|
||||
@@ -822,7 +822,7 @@ func (v *Vault) saveAssetAt(relativeDir, origName string, r io.Reader, limit int
|
||||
return "./" + EscapeLinkPath(path.Base(relativeDir)) + "/" + EscapeLinkPath(path.Base(target)), nil
|
||||
}
|
||||
|
||||
// 管理界面允许替换图片,文件名不保证内容未变;只复用实际内容相同的普通文件。
|
||||
// Image management permits replacement, so filenames do not guarantee unchanged contents; reuse only regular files with matching contents.
|
||||
func (v *Vault) assetDestination(dir, name string, data []byte) (string, bool, error) {
|
||||
base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
|
||||
for i := 0; ; i++ {
|
||||
@@ -879,7 +879,7 @@ func sniffExt(data []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
// EscapeLinkPath 只转义会破坏 Markdown 链接目标的字符,中文等保持原样(与 Typora 写法一致)
|
||||
// EscapeLinkPath escapes only characters that break Markdown link destinations, preserving Unicode such as Chinese to match Typora.
|
||||
func EscapeLinkPath(s string) string {
|
||||
return linkEscaper.Replace(s)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func newVault(t *testing.T) *Vault {
|
||||
return v
|
||||
}
|
||||
|
||||
// 显式构造旧版独立附件,覆盖升级后的移动与回收站恢复。
|
||||
// Construct legacy dedicated attachments explicitly to cover moves and recycle-bin restores after upgrading.
|
||||
func legacyAsset(t *testing.T, v *Vault, note, name, content string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note)))
|
||||
@@ -85,7 +85,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyAsset(t, v, "n.md", "x.png", "image")
|
||||
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||
// The note name fits, but its derived .assets name exceeds the filesystem's per-segment length limit.
|
||||
destination := strings.Repeat("a", 250) + ".md"
|
||||
if _, err := v.Move("n.md", destination); err == nil {
|
||||
t.Fatal("图片目录移动应失败")
|
||||
|
||||
Reference in new issue
Block a user