Preserve code samples and watcher state during file updates
This commit is contained in:
1 parent
3a8248e560
commit
2df1ab87df
4 files changed
+218
-6
No files matched your search
+101
-5
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
|
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
|
||||||
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过围栏代码块和行内代码。
|
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码块和行内代码。
|
||||||
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
|
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
|
||||||
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
|
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
|
||||||
oldEsc := regexp.QuoteMeta(EscapeLinkPath(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"))
|
lines := bytes.SplitAfter(content, []byte("\n"))
|
||||||
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
|
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
|
||||||
|
var containers []linkContainer
|
||||||
|
paragraph := false
|
||||||
for i, line := range lines {
|
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 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
|
fence = nil
|
||||||
}
|
}
|
||||||
continue
|
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
|
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)
|
line = refDef.ReplaceAll(line, repl)
|
||||||
lines[i] = replaceOutsideCode(line, inline, repl)
|
lines[i] = replaceOutsideCode(line, inline, repl)
|
||||||
}
|
}
|
||||||
return bytes.Join(lines, nil)
|
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 个以上 ` 或 ~),返回围栏字符串
|
// fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串
|
||||||
func fenceOpen(line []byte) []byte {
|
func fenceOpen(line []byte) []byte {
|
||||||
indent := len(line) - len(bytes.TrimLeft(line, " "))
|
indent := len(line) - len(bytes.TrimLeft(line, " "))
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewriteAssetLinksPreservesCodeBlocks(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"缩进", " \n"},
|
||||||
|
{"制表符", "\t\n"},
|
||||||
|
{"引用围栏", "> ```markdown\n> \n> ```\n"},
|
||||||
|
{"引用缩进", "> \n"},
|
||||||
|
{"列表围栏", "- ```markdown\n \n ```\n"},
|
||||||
|
{"列表缩进", "- 项目\n\n \n"},
|
||||||
|
{"有序列表缩进", "10. 项目\n\n \n"},
|
||||||
|
{"引用内嵌列表", "> - ```markdown\n> \n> ```\n"},
|
||||||
|
{"围栏内四格关闭标记", "```markdown\n ```\n\n```\n"},
|
||||||
|
{"标题后缩进代码", "# 标题\n \n"},
|
||||||
|
{"分隔线后缩进代码", "* * *\n \n"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
input := tc.code + "\n\n"
|
||||||
|
want := tc.code + "\n\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{
|
||||||
|
"> \n",
|
||||||
|
"- \n",
|
||||||
|
"- 项目\n\n \n",
|
||||||
|
"- 项目\n - 子项\n \n",
|
||||||
|
"段落\n \n",
|
||||||
|
"> 段落\n \n",
|
||||||
|
"- 段落\n\n \n",
|
||||||
|
"10. 项目\n\n \n",
|
||||||
|
"- 项目\n - 子项\n ```markdown\n \n ```\n\n \n",
|
||||||
|
"> ```markdown\n> \n\n\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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ package watch
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -139,9 +142,12 @@ func (w *Watcher) scan() (map[string]fileState, error) {
|
|||||||
st.rev = old.rev
|
st.rev = old.rev
|
||||||
} else {
|
} else {
|
||||||
data, err := os.ReadFile(abs)
|
data, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
continue // 扫描期间被删除,下一轮会处理
|
continue // 扫描期间被删除,下一轮会处理
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取笔记 %s 失败: %w", e.Path, err)
|
||||||
|
}
|
||||||
st.rev = vault.Rev(data)
|
st.rev = vault.Rev(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,65 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestDiff(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
if err := os.Mkdir(filepath.Join(root, "d"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(root, "d"), 0o755); err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user