diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go
index db0a404..7072f85 100644
--- a/cmd/rawmark/main.go
+++ b/cmd/rawmark/main.go
@@ -72,9 +72,9 @@ func run(addr string) error {
return err
}
defer idx.Close()
- go idx.Run(ctx)
-
hub := events.NewHub()
+ idx.OnIndexed = func() { hub.Broadcast(events.Event{Type: "indexed"}) }
+ go idx.Run(ctx)
if err := watch.Start(ctx, v, hub, idx.Update); err != nil {
return fmt.Errorf("启动文件监听失败: %w", err)
}
diff --git a/go.mod b/go.mod
index f897e70..5d27efc 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,8 @@ go 1.27.2
require (
github.com/coder/websocket v1.8.15
github.com/fsnotify/fsnotify v1.10.1
+ github.com/yuin/goldmark/v2 v2.1.8
+ go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.60.1
)
diff --git a/go.sum b/go.sum
index 1a90227..3adc103 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/yuin/goldmark/v2 v2.1.8 h1:gDvS0JFb8YcCRXFzjorsFq10FAgpVDXRZ/ze+hD4sk4=
+github.com/yuin/goldmark/v2 v2.1.8/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
diff --git a/internal/api/api.go b/internal/api/api.go
index a0f22c8..ab85459 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
{"GET /api/v1/tree", authed, s.tree},
+ {"GET /api/v1/metadata", authed, s.metadata},
{"POST /api/v1/export", authed, s.export},
{"GET /api/v1/files/{path...}", authed, s.readFile},
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
@@ -522,3 +523,12 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) {
internalError(w, r, err)
}
}
+
+func (s *Server) metadata(w http.ResponseWriter, r *http.Request) {
+ notes, ready, err := s.index.Metadata()
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ writeJSON(w, 200, map[string]any{"notes": notes, "ready": ready})
+}
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index c218dcf..21b9991 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -2431,6 +2431,24 @@
}
}
}
+ },
+ "/metadata": {
+ "get": {
+ "summary": "读取笔记关系、标签与资源引用索引",
+ "security": [
+ {
+ "cookie": []
+ },
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "索引就绪状态与元数据列表,不含笔记正文"
+ }
+ }
+ }
}
},
"components": {
diff --git a/internal/search/search.go b/internal/search/search.go
index cf9196d..5308a3c 100644
--- a/internal/search/search.go
+++ b/internal/search/search.go
@@ -5,6 +5,7 @@ package search
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"fmt"
"log/slog"
@@ -19,13 +20,14 @@ import (
)
// 修改表结构时递增,旧索引会被丢弃重建
-const schemaVersion = 1
+const schemaVersion = 2
const schema = `
CREATE TABLE docs (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
- rev TEXT NOT NULL
+ rev TEXT NOT NULL,
+ metadata TEXT NOT NULL
);
-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE
CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram');
@@ -50,8 +52,11 @@ type doc struct {
}
type Index struct {
- db *sql.DB
- v *vault.Vault
+ OnIndexed func()
+ ready bool
+ lastError error
+ db *sql.DB
+ v *vault.Vault
mu sync.Mutex
pending map[string]string // 待同步的最新快照(路径 -> rev)
@@ -126,7 +131,12 @@ func (x *Index) Run(ctx context.Context) {
files := x.pending
x.mu.Unlock()
if err := x.sync(files); err != nil {
+ x.mu.Lock()
+ x.lastError = err
+ x.mu.Unlock()
slog.Error("更新搜索索引失败", "err", err)
+ } else if x.OnIndexed != nil {
+ x.OnIndexed()
}
}
}
@@ -168,7 +178,11 @@ func (x *Index) sync(files map[string]string) error {
return err
}
}
- res, err := tx.Exec(`INSERT INTO docs (path, rev) VALUES (?, ?)`, p, rev)
+ metadata, err := json.Marshal(vault.Analyze(p, content))
+ if err != nil {
+ return err
+ }
+ res, err := tx.Exec(`INSERT INTO docs (path, rev, metadata) VALUES (?, ?, ?)`, p, rev, string(metadata))
if err != nil {
return err
}
@@ -185,6 +199,10 @@ func (x *Index) sync(files map[string]string) error {
return err
}
x.known = next
+ x.mu.Lock()
+ x.ready = true
+ x.lastError = nil
+ x.mu.Unlock()
return nil
}
@@ -308,3 +326,36 @@ func indexRunes(s, sub []rune) int {
}
return -1
}
+
+type NoteMetadata struct {
+ Path string `json:"path"`
+ Rev string `json:"rev"`
+ vault.Metadata
+}
+
+func (x *Index) Metadata() ([]NoteMetadata, bool, error) {
+ x.mu.Lock()
+ ready, lastError := x.ready, x.lastError
+ x.mu.Unlock()
+ if lastError != nil {
+ return nil, ready, lastError
+ }
+ notes := []NoteMetadata{}
+ rows, err := x.db.Query(`SELECT path, rev, metadata FROM docs ORDER BY path`)
+ if err != nil {
+ return nil, ready, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var note NoteMetadata
+ var data string
+ if err := rows.Scan(¬e.Path, ¬e.Rev, &data); err != nil {
+ return nil, ready, err
+ }
+ if err := json.Unmarshal([]byte(data), ¬e.Metadata); err != nil {
+ return nil, ready, err
+ }
+ notes = append(notes, note)
+ }
+ return notes, ready, rows.Err()
+}
diff --git a/internal/search/search_test.go b/internal/search/search_test.go
index e12e267..e3a3c70 100644
--- a/internal/search/search_test.go
+++ b/internal/search/search_test.go
@@ -92,15 +92,15 @@ 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", // 命中路径
- "不存在的词": "",
- "100%": "",
- `"引号"`: "",
- " ": "",
+ "装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS
+ "检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE
+ "codemirror": "a.md", // 不区分大小写
+ "装饰器 检索": "目录/c.md", // 多个词同时满足
+ "目录": "目录/c.md", // 命中路径
+ "不存在的词": "",
+ "100%": "",
+ `"引号"`: "",
+ " ": "",
}
for q, want := range cases {
if got := f.paths(q); got != want && !(strings.Contains(want, ",") && sameSet(got, want)) {
@@ -179,3 +179,29 @@ func TestSyncTracksChanges(t *testing.T) {
t.Errorf("重启后搜索 = %q", got)
}
}
+
+func TestMetadataTracksChangesAndRebuild(t *testing.T) {
+ f := newFixture(t)
+ _, ready, err := f.idx.Metadata()
+ if ready || err != nil {
+ t.Fatalf("初始状态错误:%v %v", ready, err)
+ }
+ f.write("a.md", "#one [b](b.md)")
+ f.sync()
+ notes, ready, err := f.idx.Metadata()
+ if err != nil || !ready || len(notes) != 1 || len(notes[0].Links) != 1 || len(notes[0].Tags) != 1 {
+ t.Fatalf("元数据错误:%+v %v %v", notes, ready, err)
+ }
+ f.write("a.md", "#two")
+ f.sync()
+ notes, _, _ = f.idx.Metadata()
+ if len(notes[0].Links) != 0 || notes[0].Tags[0] != "two" {
+ t.Fatalf("元数据未更新:%+v", notes)
+ }
+ f.reopen()
+ f.sync()
+ notes, ready, err = f.idx.Metadata()
+ if err != nil || !ready || len(notes) != 1 || notes[0].Tags[0] != "two" {
+ t.Fatalf("索引恢复错误:%+v %v", notes, err)
+ }
+}
diff --git a/internal/vault/images.go b/internal/vault/images.go
index 10b35bc..aa1dacd 100644
--- a/internal/vault/images.go
+++ b/internal/vault/images.go
@@ -7,7 +7,6 @@ import (
"html"
"io"
"io/fs"
- "net/url"
"os"
"path"
"path/filepath"
@@ -48,60 +47,73 @@ func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) err
return atomicWrite(p, data)
}
-type imageLinkChange struct {
+type linkChange struct {
path string
before, after []byte
}
-// 调用方持有写锁;所有原始内容留到移动成功,以便复合操作失败时回滚。
-func (v *Vault) imageLinkChanges(from, to string) ([]imageLinkChange, error) {
+// 调用方持有写锁;先计算全部改写,移动成功后再写入,出错时保留回滚依据。
+func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool) ([]linkChange, error) {
entries, err := v.Tree()
if err != nil {
return nil, err
}
- var changes []imageLinkChange
- for _, e := range entries {
- if e.Dir || !IsNote(e.Path) {
+ var changes []linkChange
+ for _, entry := range entries {
+ if entry.Dir || !IsNote(entry.Path) {
continue
}
- p := filepath.Join(v.root, filepath.FromSlash(e.Path))
- before, err := os.ReadFile(p)
+ before, _, err := v.Read(entry.Path)
if err != nil {
return nil, err
}
- after, err := rewriteImageLinks(before, e.Path, from, to)
+ next := movedPath(entry.Path, from, to, directory)
+ after, err := rewriteMovedLinks(before, entry.Path, next, from, to, directory, ownAssets)
if err != nil {
return nil, err
}
+ if ownAssets && entry.Path == from {
+ after = RewriteAssetLinks(after, strings.TrimSuffix(path.Base(from), NoteExt), strings.TrimSuffix(path.Base(to), NoteExt))
+ }
if !bytes.Equal(before, after) {
- changes = append(changes, imageLinkChange{p, before, after})
+ changes = append(changes, linkChange{filepath.Join(v.root, filepath.FromSlash(next)), before, after})
}
}
return changes, nil
}
-func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) {
- rel, err := filepath.Rel(filepath.FromSlash(path.Dir(note)), filepath.FromSlash(to))
- if err != nil {
- return nil, err
+func movedPath(p, from, to string, directory bool) string {
+ if p == from {
+ return to
}
- spans := importLinkSpans(content)
+ if directory && strings.HasPrefix(p, from+"/") {
+ return to + strings.TrimPrefix(p, from)
+ }
+ return p
+}
+
+func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) {
var out []byte
last := 0
- for _, span := range spans {
+ for _, span := range importLinkSpans(content) {
if span.start < last {
continue
}
- target := string(content[span.start:span.end])
- if span.html {
- target = html.UnescapeString(target)
- } else {
- target = markdownEscape.ReplaceAllString(target, "$1")
- }
- u, err := url.Parse(target)
- if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" || path.Clean(path.Join(path.Dir(note), u.Path)) != from {
+ target, u, ok := LocalTarget(oldNote, string(content[span.start:span.end]))
+ if !ok {
continue
}
+ mapped := movedPath(target, from, to, directory)
+ if ownAssets {
+ mapped = movedPath(mapped, AssetsDir(from), AssetsDir(to), true)
+ }
+ if mapped == target && path.Dir(oldNote) == path.Dir(newNote) {
+ continue
+ }
+ rel, err := filepath.Rel(filepath.FromSlash(path.Dir(newNote)), filepath.FromSlash(mapped))
+ if err != nil {
+ return nil, err
+ }
replacement := EscapeLinkPath(filepath.ToSlash(rel))
if u.RawQuery != "" {
replacement += "?" + u.RawQuery
diff --git a/internal/vault/metadata.go b/internal/vault/metadata.go
new file mode 100644
index 0000000..d6609f3
--- /dev/null
+++ b/internal/vault/metadata.go
@@ -0,0 +1,169 @@
+package vault
+
+import (
+ "bytes"
+ "fmt"
+ "html"
+ "net/url"
+ "path"
+ "regexp"
+ "sort"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/yuin/goldmark/v2/ast"
+ "github.com/yuin/goldmark/v2/parser"
+ "go.yaml.in/yaml/v3"
+)
+
+type Reference struct {
+ Target string `json:"target"`
+ Line int `json:"line"`
+}
+
+type Metadata struct {
+ Title string `json:"title"`
+ Tags []string `json:"tags"`
+ Links []Reference `json:"links"`
+ Assets []Reference `json:"assets"`
+ Warnings []string `json:"warnings"`
+}
+
+var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`)
+
+// LocalTarget 在链接边界解码 Markdown 和 URL,拒绝远程地址、绝对路径及越界路径。
+func LocalTarget(note, raw string) (string, *url.URL, bool) {
+ raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1"))
+ u, err := url.Parse(raw)
+ if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(raw, "/") || u.Path == "" {
+ return "", nil, false
+ }
+ target := path.Clean(path.Join(path.Dir(note), u.Path))
+ if _, err := CleanPath(target); err != nil {
+ return "", nil, false
+ }
+ return target, u, true
+}
+
+// Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。
+func Analyze(note string, content []byte) Metadata {
+ m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
+ tags := map[string]bool{}
+ addTag := func(s string) {
+ s = strings.TrimPrefix(strings.TrimSpace(s), "#")
+ if s != "" {
+ tags[s] = true
+ }
+ }
+ source := bytes.Clone(content)
+ lines := bytes.SplitAfter(source, []byte("\n"))
+ if len(lines) > 0 && strings.TrimSpace(string(lines[0])) == "---" {
+ end, bodyEnd, offset := -1, 0, len(lines[0])
+ for i := 1; i < len(lines); i++ {
+ if strings.TrimSpace(string(lines[i])) == "---" {
+ bodyEnd = offset
+ end = offset + len(lines[i])
+ break
+ }
+ offset += len(lines[i])
+ }
+ if end < 0 {
+ m.Warnings = append(m.Warnings, "YAML 元数据未闭合")
+ end = len(source)
+ } else {
+ var front struct {
+ Tags yaml.Node `yaml:"tags"`
+ Title string `yaml:"title"`
+ }
+ if err := yaml.Unmarshal(source[len(lines[0]):bodyEnd], &front); err != nil {
+ m.Warnings = append(m.Warnings, fmt.Sprintf("YAML 元数据无效:%v", err))
+ } else {
+ if front.Title != "" {
+ m.Title = front.Title
+ }
+ switch front.Tags.Kind {
+ case 0:
+ case yaml.ScalarNode:
+ if front.Tags.Tag == "!!str" {
+ for _, tag := range strings.FieldsFunc(front.Tags.Value, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) {
+ addTag(tag)
+ }
+ } else {
+ m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表")
+ }
+ case yaml.SequenceNode:
+ for _, tag := range front.Tags.Content {
+ if tag.Kind != yaml.ScalarNode || tag.Tag != "!!str" {
+ m.Warnings = append(m.Warnings, "YAML tags 必须是字符串列表")
+ break
+ }
+ addTag(tag.Value)
+ }
+ default:
+ m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表")
+ }
+ }
+ }
+ for i := 0; i < end; i++ {
+ if source[i] != '\n' && source[i] != '\r' {
+ source[i] = ' '
+ }
+ }
+ }
+ root := parser.New().Parse(source)
+ _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ addRef := func(raw string, pos int) {
+ target, _, ok := LocalTarget(note, raw)
+ if !ok {
+ return
+ }
+ ref := Reference{target, bytes.Count(source[:max(0, pos)], []byte("\n")) + 1}
+ if IsNote(target) {
+ m.Links = append(m.Links, ref)
+ } else {
+ m.Assets = append(m.Assets, ref)
+ }
+ }
+ switch node := n.(type) {
+ case *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition:
+ return ast.WalkSkipChildren, nil
+ case *ast.Link:
+ addRef(node.Destination.Value(source), node.Pos())
+ return ast.WalkSkipChildren, nil
+ case *ast.Image:
+ addRef(node.Destination.Value(source), node.Pos())
+ return ast.WalkSkipChildren, nil
+ case *ast.Text:
+ value := node.Value.Value(source)
+ for _, loc := range inlineTag.FindAllStringIndex(value, -1) {
+ if loc[0] > 0 {
+ prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]])
+ if unicode.IsLetter(prev) || unicode.IsNumber(prev) || prev == '/' || prev == '\\' || prev == '&' {
+ continue
+ }
+ }
+ addTag(value[loc[0]:loc[1]])
+ }
+ }
+ return ast.WalkContinue, nil
+ })
+ // HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
+ for _, span := range importLinkSpans(content) {
+ if !span.html {
+ continue
+ }
+ target, _, ok := LocalTarget(note, string(content[span.start:span.end]))
+ if ok {
+ m.Assets = append(m.Assets, Reference{target, bytes.Count(content[:span.start], []byte("\n")) + 1})
+ }
+ }
+ for tag := range tags {
+ m.Tags = append(m.Tags, tag)
+ }
+ sort.Strings(m.Tags)
+ return m
+}
diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go
new file mode 100644
index 0000000..0281b88
--- /dev/null
+++ b/internal/vault/metadata_test.go
@@ -0,0 +1,75 @@
+package vault
+
+import (
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestAnalyzeRelationshipsAndTags(t *testing.T) {
+ content := "---\ntags: [知识, daily]\n---\n# 标题\n正文 #读书 和 #tag/nested\n[笔记](../目标.md#章节)  [引用][ref]\n\n[ref]: other.md\n\n`#code [伪](bad.md)`\n```\n#not-tag\n[fake](fake.md)\n```\n"
+ m := Analyze("dir/a.md", []byte(content))
+ if !reflect.DeepEqual(m.Tags, []string{"daily", "tag/nested", "知识", "读书"}) {
+ t.Fatalf("标签不符:%v", m.Tags)
+ }
+ if len(m.Links) != 2 || m.Links[0].Target != "目标.md" || m.Links[1].Target != "dir/other.md" || m.Links[0].Line != 6 {
+ t.Fatalf("链接不符:%+v", m.Links)
+ }
+ if len(m.Assets) != 1 || m.Assets[0].Target != "dir/a.assets/p.png" {
+ t.Fatalf("图片引用不符:%+v", m.Assets)
+ }
+ if len(m.Warnings) != 0 {
+ t.Fatal(m.Warnings)
+ }
+ for _, content := range []string{"---\ntags: [broken\n---\n", "---\ntags: {key: value}\n---", "---\ntags: 12\n---", "---\ntags: x"} {
+ if len(Analyze("x.md", []byte(content)).Warnings) == 0 {
+ t.Fatalf("无效元数据未报告:%s", content)
+ }
+ }
+}
+
+func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
+ v := newVault(t)
+ for p, body := range map[string]string{
+ "dir/a.md": "[外部](../other.md) [同级](b.md#section) \n`[不改](b.md)`\n",
+ "dir/b.md": "[入链](a.md?q=1#section)", "other.md": "[入链](dir/a.md)",
+ } {
+ if _, err := v.Write(p, []byte(body), Precondition{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := v.Move("dir/a.md", "new/renamed.md", nil); err != nil {
+ t.Fatal(err)
+ }
+ body, _, _ := v.Read("new/renamed.md")
+ if !strings.Contains(string(body), "../dir/b.md#section") || !strings.Contains(string(body), "renamed.assets/p.png") || !strings.Contains(string(body), "`[不改](b.md)`") {
+ t.Fatalf("出链错误:%s", body)
+ }
+ body, _, _ = v.Read("dir/b.md")
+ if string(body) != "[入链](../new/renamed.md?q=1#section)" {
+ t.Fatalf("入链错误:%s", body)
+ }
+ if _, err := v.Move("new", "deep/folder", nil); err != nil {
+ t.Fatal(err)
+ }
+ body, _, _ = v.Read("deep/folder/renamed.md")
+ if !strings.Contains(string(body), "../../other.md") {
+ t.Fatalf("目录出链错误:%s", body)
+ }
+ original, _, _ := v.Read("other.md")
+ failure := errors.New("提交失败")
+ if _, err := v.Move("deep/folder", "rollback", func() error { return failure }); !errors.Is(err, failure) {
+ t.Fatal(err)
+ }
+ body, _, _ = v.Read("other.md")
+ if string(body) != string(original) {
+ t.Fatalf("入链未回滚:%s", body)
+ }
+ if _, _, err := v.Read("deep/folder/renamed.md"); err != nil {
+ t.Fatal("移动主体未回滚", err)
+ }
+}
diff --git a/internal/vault/vault.go b/internal/vault/vault.go
index f767e2f..50d3f74 100644
--- a/internal/vault/vault.go
+++ b/internal/vault/vault.go
@@ -440,13 +440,10 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
}
}
}
- // 图片改名影响引用它的笔记:先读取全部待改正文,失败时不能先移动图片。
- var links []imageLinkChange
- if isImage {
- links, err = v.imageLinkChanges(from, to)
- if err != nil {
- return false, err
- }
+ // 笔记、目录和图片的移动都可能改变跨文件相对链接。
+ links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "")
+ if err != nil {
+ return false, err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return false, err
@@ -456,19 +453,13 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
}
assetsMoved := false
writtenLinks := 0
- var originalNote []byte
defer func() {
if err == nil {
return
}
- if rewritten {
- if rollback := atomicWrite(dst, originalNote); rollback != nil {
- err = errors.Join(err, fmt.Errorf("回滚笔记正文失败: %w", rollback))
- }
- }
for _, change := range links[:writtenLinks] {
if rollback := atomicWrite(change.path, change.before); rollback != nil {
- err = errors.Join(err, fmt.Errorf("回滚图片引用失败: %w", rollback))
+ err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback))
}
}
if assetsMoved {
@@ -485,27 +476,16 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
return false, err
}
writtenLinks++
+ if change.path == dst || info.IsDir() && strings.HasPrefix(change.path, dst+string(filepath.Separator)) {
+ rewritten = true
+ }
}
if srcAssets != "" {
if err := os.Rename(srcAssets, dstAssets); err != nil {
return false, fmt.Errorf("移动图片目录失败: %w", err)
}
assetsMoved = true
- oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
- newBase := strings.TrimSuffix(path.Base(to), NoteExt)
- if oldBase != newBase {
- data, err := os.ReadFile(dst)
- if err != nil {
- return false, err
- }
- if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
- if err = atomicWrite(dst, out); err != nil {
- return false, err
- }
- originalNote = data
- rewritten = true
- }
- }
+
}
if commit != nil {
err = commit()
diff --git a/web/src/app/NoteRelations.vue b/web/src/app/NoteRelations.vue
new file mode 100644
index 0000000..8b46071
--- /dev/null
+++ b/web/src/app/NoteRelations.vue
@@ -0,0 +1,26 @@
+
+
+ {{ metadata.error }} 关系索引准备中… 暂无其他笔记链接到这里 {{ warning }}反向链接 {{ incoming.length }}
+
没有标题
+