From a923c2dfb9792aba59154f7d38c9222c5f304d7b Mon Sep 17 00:00:00 2001 From: eezd Date: Thu, 22 Oct 2026 14:52:37 +0800 Subject: [PATCH] feat(knowledge): index note relationships and maintain moved links --- cmd/rawmark/main.go | 4 +- go.mod | 2 + go.sum | 4 + internal/api/api.go | 10 ++ internal/api/openapi.json | 18 ++++ internal/search/search.go | 61 +++++++++++- internal/search/search_test.go | 44 +++++++-- internal/vault/images.go | 62 +++++++----- internal/vault/metadata.go | 169 ++++++++++++++++++++++++++++++++ internal/vault/metadata_test.go | 75 ++++++++++++++ internal/vault/vault.go | 38 ++----- web/src/app/NoteRelations.vue | 26 +++++ web/src/app/OutlinePanel.vue | 2 + web/src/app/api.ts | 11 +++ web/src/app/events.ts | 2 +- web/src/app/metadata.ts | 17 ++++ web/src/app/workspace.ts | 53 +++++++++- web/src/editor/index.ts | 4 +- web/src/editor/livepreview.ts | 22 ++++- 19 files changed, 545 insertions(+), 79 deletions(-) create mode 100644 internal/vault/metadata.go create mode 100644 internal/vault/metadata_test.go create mode 100644 web/src/app/NoteRelations.vue create mode 100644 web/src/app/metadata.ts 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#章节) ![图片](a.assets/p.png) [引用][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) ![](a.assets/p.png)\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 @@ + + + diff --git a/web/src/app/OutlinePanel.vue b/web/src/app/OutlinePanel.vue index cd0acff..7bf362c 100644 --- a/web/src/app/OutlinePanel.vue +++ b/web/src/app/OutlinePanel.vue @@ -4,6 +4,7 @@ import type { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import Icon from './Icon.vue' +import NoteRelations from './NoteRelations.vue' import { activeTab, editorView, onEditorUpdate, ws } from './workspace' // 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节 @@ -102,6 +103,7 @@ const minLevel = () => Math.min(...headings.value.map((h) => h.level))

没有标题

+ diff --git a/web/src/app/api.ts b/web/src/app/api.ts index 4a64ff6..a5a5361 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -14,6 +14,16 @@ export interface ImportResult { warnings: string[] } +export interface NoteMetadata { + path: string + rev: string + title: string + tags: string[] + links: { target: string; line: number }[] + assets: { target: string; line: number }[] + warnings: string[] +} + export interface Theme { id: string name: string @@ -145,6 +155,7 @@ export const api = { exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }), + metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')), tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries, search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results, diff --git a/web/src/app/events.ts b/web/src/app/events.ts index 05b8f17..8231ea8 100644 --- a/web/src/app/events.ts +++ b/web/src/app/events.ts @@ -1,7 +1,7 @@ // 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步 export interface VaultEvent { - type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' + type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed' path?: string from?: string rev?: string diff --git a/web/src/app/metadata.ts b/web/src/app/metadata.ts new file mode 100644 index 0000000..9561784 --- /dev/null +++ b/web/src/app/metadata.ts @@ -0,0 +1,17 @@ +import { reactive } from 'vue' +import { api, type NoteMetadata } from './api' + +export const metadata = reactive({ notes: [] as NoteMetadata[], ready: false, error: '' }) +let request = 0 +export async function refreshMetadata() { + const current = ++request + try { + const result = await api.metadata() + if (current !== request) return + metadata.notes = result.notes + metadata.ready = result.ready + metadata.error = '' + } catch (error) { + if (current === request) metadata.error = error instanceof Error ? error.message : '关系索引读取失败' + } +} diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index 87ee54f..90ff748 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -9,6 +9,7 @@ import { createEditorState, insertImageFiles, pasteImages, languageCompartment, import { handleShortcut, registerCommand } from './commands' import { api, ApiError, encodePath, type Entry } from './api' import { drafts } from './drafts' +import { refreshMetadata } from './metadata' import { connectEvents, type VaultEvent } from './events' import { basename, dirname, joinPath, isImage, NOTE_EXT, resolveAssetUrl } from './paths' import { loadSettings, settings } from './settings' @@ -201,6 +202,7 @@ function buildState(path: string, doc: string): EditorState { spellcheck: settings.spellcheck, tabSize: settings.tabSize, sourceMode: ws.sourceMode, + openLink: (href) => { void openMarkdownLink(ref.path, href) }, resolveImage: (src) => { const url = resolveAssetUrl(ref.path, src) const entry = ws.entries.find((e) => url === '/api/v1/raw/' + encodePath(e.path)) @@ -743,9 +745,8 @@ export async function move(from: string, to: string): Promise { return operationFailed(`“${basename(to)}”已存在`) } const result = await withOp(async () => { - // 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。 - const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir) - const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from) + // 移动会更新仓库中的相对链接,先保存全部已打开笔记,沿用版本冲突保护。 + const impacted = () => ws.tabs.map((t) => t.path) const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused) if (busy()) { return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动') @@ -792,7 +793,7 @@ export async function move(from: string, to: string): Promise { paused.forEach((s) => s.paused--) } // 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步 - await Promise.all((imageMove ? ws.tabs.map((t) => t.path) : affected(to)).map(async (p) => { + await Promise.all(ws.tabs.map((t) => t.path).map(async (p) => { const tab = tabOf(p) if (!tab) return if (tab.dirty) { @@ -877,7 +878,8 @@ async function syncOpenTab(tab: Tab) { async function handleEvents(evs: VaultEvent[]) { for (const ev of evs) { - if (ev.type === 'settings') { + if (ev.type === 'indexed') void refreshMetadata() + else if (ev.type === 'settings') { void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err))) void refreshPlugins() } @@ -916,6 +918,7 @@ async function handleEvents(evs: VaultEvent[]) { } async function resync() { + void refreshMetadata() await refreshTree() await Promise.all(ws.tabs.map(syncOpenTab)) pluginEvents.dispatchEvent(new CustomEvent('resync')) @@ -1128,3 +1131,43 @@ export function stopWorkspace() { disconnect?.() disconnect = undefined } + + +async function openMarkdownLink(note: string, href: string) { + const decoded = href.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g, '$1') + let url: URL + try { url = new URL(decoded, 'https://rawmark.local/' + encodePath(note)) } + catch { toast('链接格式无效'); return } + if (url.origin !== 'https://rawmark.local') { + if (['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) window.open(url.href, '_blank', 'noopener') + else toast('不支持的链接协议') + return + } + const target = decodeURIComponent(url.pathname.slice(1)) + if (!ws.entries.some(e => e.path === target && !e.dir)) { toast('链接目标不存在:' + target); return } + if (!target.endsWith('.md')) { window.open('/api/v1/raw/' + encodePath(target), '_blank', 'noopener'); return } + await openNote(target) + if (url.hash) requestAnimationFrame(() => { + const state = editorStateOf(target), v = editorView() + if (!state || !v || ws.active !== target) return + const heading = decodeURIComponent(url.hash.slice(1)).toLocaleLowerCase() + for (let i = 1; i <= state.doc.lines; i++) { + const line = state.doc.line(i) + const title = line.text.replace(/^#{1,6}\s+/, '').trim().toLocaleLowerCase() + if (/^#{1,6}\s/.test(line.text) && (title === heading || title.replace(/\s+/g, '-') === heading)) { + v.dispatch({ selection: { anchor: line.from }, effects: EditorView.scrollIntoView(line.from, { y: 'start' }) }); break + } + } + }) +} + +export async function openReference(path: string, line: number) { + await openNote(path) + requestAnimationFrame(() => { + const v = editorView() + if (!v || ws.active !== path) return + const pos = v.state.doc.line(Math.min(line, v.state.doc.lines)).from + v.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: 'center' }) }) + v.focus() + }) +} diff --git a/web/src/editor/index.ts b/web/src/editor/index.ts index 06b2948..a622f7d 100644 --- a/web/src/editor/index.ts +++ b/web/src/editor/index.ts @@ -9,7 +9,7 @@ import { renderedBlocks } from './blocks' import { codeBlockHighlight } from './codeblocks' import { editorFocus } from './focus' import { defaultEditorKeys } from './shortcuts' -import { livePreview, resolveImageUrl } from './livepreview' +import { livePreview, resolveImageUrl, openLink } from './livepreview' import { tableEditing } from './table' import { markdownTheme } from './theme' import { editorExtensions, markdownSyntax, reportEditorFailure } from '../plugins/registry' @@ -40,6 +40,7 @@ export interface EditorStateOptions { sourceMode?: boolean shortcuts?: KeyBinding[] resolveImage?: (src: string) => string + openLink?: (href: string) => void // 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新 onChange?: (update: ViewUpdate) => void extensions?: Extension[] @@ -70,6 +71,7 @@ export function createEditorState(opts: EditorStateOptions): EditorState { ]), EditorView.contentAttributes.of({ autocapitalize: 'sentences' }), ] + if (opts.openLink) extensions.push(openLink.of(opts.openLink)) if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage)) if (opts.onChange) { const onChange = opts.onChange diff --git a/web/src/editor/livepreview.ts b/web/src/editor/livepreview.ts index 0fb8c9b..a8a1cfc 100644 --- a/web/src/editor/livepreview.ts +++ b/web/src/editor/livepreview.ts @@ -21,6 +21,8 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin combine: (values) => values[0] ?? ((src) => src), }) +export const openLink = Facet.define<(href: string) => void, ((href: string) => void) | undefined>({ combine: values => values[0] }) + // 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。 export const refreshImages = StateEffect.define() @@ -307,7 +309,21 @@ function linkAt(state: EditorState, pos: number): string | null { if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '') if (node.name === 'Link' || node.name === 'Autolink') { const url = node.getChild('URL') - return url ? state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '') : null + if (url) return state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '') + const text = (n: SyntaxNode) => state.sliceDoc(n.from, n.to) + const label = node.getChild('LinkLabel') + const close = node.getChildren('LinkMark').find(mark => text(mark) === ']') + if (!close) return null + const normalize = (value: string) => value.replace(/^\[|\]$/g, '').trim().replace(/\s+/g, ' ').toUpperCase() + const key = normalize(label && text(label) !== '[]' ? text(label) : state.sliceDoc(node.from, close.to)) + let destination: string | null = null + syntaxTree(state).iterate({ enter(ref) { + if (ref.name !== 'LinkReference') return + const refLabel = ref.node.getChild('LinkLabel'), refUrl = ref.node.getChild('URL') + if (destination === null && refLabel && refUrl && normalize(text(refLabel)) === key) destination = text(refUrl).replace(/^<|>$/g, '') + return false + } }) + return destination } } return null @@ -352,7 +368,9 @@ const livePreviewPlugin = ViewPlugin.fromClass( const href = pos === null ? null : linkAt(view.state, pos) if (!href) return false e.preventDefault() - window.open(href, '_blank', 'noopener') + const handler = view.state.facet(openLink) + if (handler) handler(href) + else if (/^(https?:|mailto:|tel:)/i.test(href)) window.open(href, '_blank', 'noopener') return true }, },