From 2df1ab87dfac5e896168fdce0e1ea2f8cb37d14e Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 20 Oct 2026 18:59:41 +0800 Subject: [PATCH] Preserve code samples and watcher state during file updates --- internal/vault/links.go | 106 +++++++++++++++++++++++++++++++++-- internal/vault/links_test.go | 53 ++++++++++++++++++ internal/watch/watch.go | 8 ++- internal/watch/watch_test.go | 57 +++++++++++++++++++ 4 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 internal/vault/links_test.go diff --git a/internal/vault/links.go b/internal/vault/links.go index e6db7f5..4329eda 100644 --- a/internal/vault/links.go +++ b/internal/vault/links.go @@ -7,7 +7,7 @@ import ( ) // RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。 -// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过围栏代码块和行内代码。 +// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码块和行内代码。 func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/") oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/") @@ -18,24 +18,120 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { lines := bytes.SplitAfter(content, []byte("\n")) var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中 + var containers []linkContainer + paragraph := false for i, line := range lines { - trimmed := bytes.TrimLeft(line, " ") + // 分类时展开制表符,替换仍作用于原始字节,不重新序列化 Markdown。 + body := bytes.TrimRight(expandLinkTabs(line), "\r\n") + matched := 0 + for _, container := range containers { + indent := len(body) - len(bytes.TrimLeft(body, " ")) + if container.quote { + if indent > 3 || indent == len(body) || body[indent] != '>' { + break + } + body = body[indent+1:] + if len(body) > 0 && body[0] == ' ' { + body = body[1:] + } + } else if indent == len(body) { + body = nil // 空行不结束列表容器。 + } else if indent >= container.indent { + body = body[container.indent:] + } else { + break + } + matched++ + } + if matched < len(containers) { + containers = containers[:matched] + fence = nil // 容器结束时,其内部未闭合的代码块也结束。 + // 段落允许省略引用或列表前缀的续行,不能把其缩进误认成代码。 + } if fence != nil { - if bytes.HasPrefix(trimmed, fence) && len(bytes.TrimSpace(bytes.TrimLeft(trimmed, string(fence[:1])))) == 0 { + trimmed := bytes.TrimLeft(body, " ") + if len(body)-len(trimmed) <= 3 && bytes.HasPrefix(trimmed, fence) && len(bytes.TrimSpace(bytes.TrimLeft(trimmed, string(fence[:1])))) == 0 { fence = nil } continue } - if f := fenceOpen(line); f != nil { - fence = f + // 新的引用或列表可以嵌套;列表的后续行按其内容起始列剥离缩进。 + for { + indent := len(body) - len(bytes.TrimLeft(body, " ")) + if indent > 3 || indent == len(body) { + break + } + rest := body[indent:] + if rest[0] == '>' { + containers = append(containers, linkContainer{quote: true}) + body = rest[1:] + if len(body) > 0 && body[0] == ' ' { + body = body[1:] + } + } else if paragraphBoundary.Match(rest) { + break // 分隔线优先于列表标记。 + } else if marker := listMarker.Find(rest); marker != nil { + mark := bytes.TrimRight(marker, " ") + // 有序列表只有从 1 开始且非空时才能打断段落。 + if paragraph && (len(marker) == len(rest) || (mark[0] >= '0' && mark[0] <= '9' && !bytes.Equal(mark[:len(mark)-1], []byte("1")))) { + break + } + padding := len(marker) - len(mark) + if padding == 0 || padding > 4 { + padding = 1 + } + width := indent + len(mark) + padding + containers = append(containers, linkContainer{indent: width}) + body = body[min(width, len(body)):] + } else { + break + } + paragraph = false + } + trimmed := bytes.TrimLeft(body, " ") + if len(trimmed) == 0 { + paragraph = false continue } + // 缩进代码不能打断已有段落,列表容器自身的缩进不计入这四列。 + if len(body)-len(trimmed) >= 4 && !paragraph { + continue + } + if f := fenceOpen(body); f != nil { + fence = f + paragraph = false + continue + } + paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed) line = refDef.ReplaceAll(line, repl) lines[i] = replaceOutsideCode(line, inline, repl) } return bytes.Join(lines, nil) } +type linkContainer struct { + quote bool + indent int +} + +var listMarker = regexp.MustCompile(`^(?:[-+*]|[0-9]{1,9}[.)])(?: +|$)`) +var paragraphBoundary = regexp.MustCompile(`^(?:#{1,6}(?: |$)|(?:\* *){3,}$|(?:_ *){3,}$|(?:- *){3,}$|[=-]+ *$)`) + +func expandLinkTabs(line []byte) []byte { + if !bytes.ContainsRune(line, '\t') { + return line + } + var out []byte + for _, ch := range line { + if ch == '\t' { + out = append(out, bytes.Repeat([]byte(" "), 4-len(out)%4)...) + } else { + out = append(out, ch) + } + } + return out +} + // fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串 func fenceOpen(line []byte) []byte { indent := len(line) - len(bytes.TrimLeft(line, " ")) diff --git a/internal/vault/links_test.go b/internal/vault/links_test.go new file mode 100644 index 0000000..aa686af --- /dev/null +++ b/internal/vault/links_test.go @@ -0,0 +1,53 @@ +package vault + +import ( + "strings" + "testing" +) + +func TestRewriteAssetLinksPreservesCodeBlocks(t *testing.T) { + for _, tc := range []struct { + name string + code string + }{ + {"缩进", " ![](a.assets/code.png)\n"}, + {"制表符", "\t![](a.assets/code.png)\n"}, + {"引用围栏", "> ```markdown\n> ![](a.assets/code.png)\n> ```\n"}, + {"引用缩进", "> ![](a.assets/code.png)\n"}, + {"列表围栏", "- ```markdown\n ![](a.assets/code.png)\n ```\n"}, + {"列表缩进", "- 项目\n\n ![](a.assets/code.png)\n"}, + {"有序列表缩进", "10. 项目\n\n ![](a.assets/code.png)\n"}, + {"引用内嵌列表", "> - ```markdown\n> ![](a.assets/code.png)\n> ```\n"}, + {"围栏内四格关闭标记", "```markdown\n ```\n![](a.assets/code.png)\n```\n"}, + {"标题后缩进代码", "# 标题\n ![](a.assets/code.png)\n"}, + {"分隔线后缩进代码", "* * *\n ![](a.assets/code.png)\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + input := tc.code + "\n![](a.assets/real.png)\n" + want := tc.code + "\n![](b.assets/real.png)\n" + if got := string(RewriteAssetLinks([]byte(input), "a", "b")); got != want { + t.Fatalf("代码示例应保持原样,真实链接应更新:\n got %q\nwant %q", got, want) + } + }) + } +} + +func TestRewriteAssetLinksInContainersAndParagraphs(t *testing.T) { + for _, input := range []string{ + "> ![](a.assets/real.png)\n", + "- ![](a.assets/real.png)\n", + "- 项目\n\n ![](a.assets/real.png)\n", + "- 项目\n - 子项\n ![](a.assets/real.png)\n", + "段落\n ![](a.assets/real.png)\n", + "> 段落\n ![](a.assets/real.png)\n", + "- 段落\n![](a.assets/real.png)\n ![](a.assets/real.png)\n", + "10. 项目\n\n ![](a.assets/real.png)\n", + "- 项目\n - 子项\n ```markdown\n ![](a.assets/code.png)\n ```\n\n ![](a.assets/real.png)\n", + "> ```markdown\n> ![](a.assets/code.png)\n\n![](a.assets/real.png)\n", + } { + want := strings.ReplaceAll(input, "a.assets/real.png", "b.assets/real.png") + if got := string(RewriteAssetLinks([]byte(input), "a", "b")); got != want { + t.Errorf("容器缩进与段落续行不应被当作代码:\n got %q\nwant %q", got, want) + } + } +} diff --git a/internal/watch/watch.go b/internal/watch/watch.go index ab23031..9b81159 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -4,6 +4,9 @@ package watch import ( "context" + "errors" + "fmt" + "io/fs" "log/slog" "os" "path/filepath" @@ -139,9 +142,12 @@ func (w *Watcher) scan() (map[string]fileState, error) { st.rev = old.rev } else { data, err := os.ReadFile(abs) - if err != nil { + if errors.Is(err, fs.ErrNotExist) { continue // 扫描期间被删除,下一轮会处理 } + if err != nil { + return nil, fmt.Errorf("读取笔记 %s 失败: %w", e.Path, err) + } st.rev = vault.Rev(data) } } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index d53bfdd..749ac80 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -6,8 +6,65 @@ import ( "sort" "strings" "testing" + + "github.com/fsnotify/fsnotify" + + "rawmark/internal/events" + "rawmark/internal/vault" ) +func TestUnreadableNotePreservesSnapshot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("权限回归需要以非 root 用户运行") + } + v, err := vault.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(v.Root(), "note.md") + if err := os.WriteFile(p, []byte("原文"), 0o644); err != nil { + t.Fatal(err) + } + fw, err := fsnotify.NewWatcher() + if err != nil { + t.Fatal(err) + } + defer fw.Close() + scans := 0 + hub := events.NewHub() + w := &Watcher{v: v, fw: fw, hub: hub, snap: map[string]fileState{}, watched: map[string]bool{}, onScan: func(map[string]string) { scans++ }} + w.snap, err = w.scan() + if err != nil { + t.Fatal(err) + } + old := w.snap["note.md"].rev + ch, cancel := hub.Subscribe() + defer cancel() + if err := os.WriteFile(p, []byte("修改后的较长正文"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, 0); err != nil { + t.Fatal(err) + } + defer os.Chmod(p, 0o644) + w.rescan() + if w.snap["note.md"].rev != old || scans != 0 { + t.Fatal("读取失败不能提交不完整快照或更新索引") + } + select { + case msg := <-ch: + t.Fatalf("读取失败不能广播删除事件: %s", msg) + default: + } + if err := os.Chmod(p, 0o644); err != nil { + t.Fatal(err) + } + w.rescan() + if w.snap["note.md"].rev == old || scans != 1 { + t.Fatal("恢复读取权限后应同步新版本") + } +} + func TestDiff(t *testing.T) { root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "d"), 0o755); err != nil {