feat(knowledge): index note relationships and maintain moved links
This commit is contained in:
1 parent
d34d1f2183
commit
a923c2dfb9
19 files changed
+545
-79
No files matched your search
+2
-2
@@ -72,9 +72,9 @@ func run(addr string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer idx.Close()
|
defer idx.Close()
|
||||||
go idx.Run(ctx)
|
|
||||||
|
|
||||||
hub := events.NewHub()
|
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 {
|
if err := watch.Start(ctx, v, hub, idx.Update); err != nil {
|
||||||
return fmt.Errorf("启动文件监听失败: %w", err)
|
return fmt.Errorf("启动文件监听失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ go 1.27.2
|
|||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
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
|
golang.org/x/crypto v0.58.0
|
||||||
modernc.org/sqlite v1.60.1
|
modernc.org/sqlite v1.60.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
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=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
|
|||||||
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
||||||
|
|
||||||
{"GET /api/v1/tree", authed, s.tree},
|
{"GET /api/v1/tree", authed, s.tree},
|
||||||
|
{"GET /api/v1/metadata", authed, s.metadata},
|
||||||
{"POST /api/v1/export", authed, s.export},
|
{"POST /api/v1/export", authed, s.export},
|
||||||
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
||||||
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
|
{"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)
|
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})
|
||||||
|
}
|
||||||
@@ -2431,6 +2431,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/metadata": {
|
||||||
|
"get": {
|
||||||
|
"summary": "读取笔记关系、标签与资源引用索引",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "索引就绪状态与元数据列表,不含笔记正文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package search
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -19,13 +20,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 修改表结构时递增,旧索引会被丢弃重建
|
// 修改表结构时递增,旧索引会被丢弃重建
|
||||||
const schemaVersion = 1
|
const schemaVersion = 2
|
||||||
|
|
||||||
const schema = `
|
const schema = `
|
||||||
CREATE TABLE docs (
|
CREATE TABLE docs (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
rev TEXT NOT NULL
|
rev TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL
|
||||||
);
|
);
|
||||||
-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE
|
-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE
|
||||||
CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram');
|
CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram');
|
||||||
@@ -50,8 +52,11 @@ type doc struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Index struct {
|
type Index struct {
|
||||||
db *sql.DB
|
OnIndexed func()
|
||||||
v *vault.Vault
|
ready bool
|
||||||
|
lastError error
|
||||||
|
db *sql.DB
|
||||||
|
v *vault.Vault
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
pending map[string]string // 待同步的最新快照(路径 -> rev)
|
pending map[string]string // 待同步的最新快照(路径 -> rev)
|
||||||
@@ -126,7 +131,12 @@ func (x *Index) Run(ctx context.Context) {
|
|||||||
files := x.pending
|
files := x.pending
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
if err := x.sync(files); err != nil {
|
if err := x.sync(files); err != nil {
|
||||||
|
x.mu.Lock()
|
||||||
|
x.lastError = err
|
||||||
|
x.mu.Unlock()
|
||||||
slog.Error("更新搜索索引失败", "err", err)
|
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
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -185,6 +199,10 @@ func (x *Index) sync(files map[string]string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
x.known = next
|
x.known = next
|
||||||
|
x.mu.Lock()
|
||||||
|
x.ready = true
|
||||||
|
x.lastError = nil
|
||||||
|
x.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,3 +326,36 @@ func indexRunes(s, sub []rune) int {
|
|||||||
}
|
}
|
||||||
return -1
|
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()
|
||||||
|
}
|
||||||
@@ -92,15 +92,15 @@ func TestSearchTerms(t *testing.T) {
|
|||||||
f.sync()
|
f.sync()
|
||||||
|
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS
|
"装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS
|
||||||
"检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE
|
"检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE
|
||||||
"codemirror": "a.md", // 不区分大小写
|
"codemirror": "a.md", // 不区分大小写
|
||||||
"装饰器 检索": "目录/c.md", // 多个词同时满足
|
"装饰器 检索": "目录/c.md", // 多个词同时满足
|
||||||
"目录": "目录/c.md", // 命中路径
|
"目录": "目录/c.md", // 命中路径
|
||||||
"不存在的词": "",
|
"不存在的词": "",
|
||||||
"100%": "",
|
"100%": "",
|
||||||
`"引号"`: "",
|
`"引号"`: "",
|
||||||
" ": "",
|
" ": "",
|
||||||
}
|
}
|
||||||
for q, want := range cases {
|
for q, want := range cases {
|
||||||
if got := f.paths(q); got != want && !(strings.Contains(want, ",") && sameSet(got, want)) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-25
@@ -7,7 +7,6 @@ import (
|
|||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -48,60 +47,73 @@ func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) err
|
|||||||
return atomicWrite(p, data)
|
return atomicWrite(p, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
type imageLinkChange struct {
|
type linkChange struct {
|
||||||
path string
|
path string
|
||||||
before, after []byte
|
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()
|
entries, err := v.Tree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var changes []imageLinkChange
|
var changes []linkChange
|
||||||
for _, e := range entries {
|
for _, entry := range entries {
|
||||||
if e.Dir || !IsNote(e.Path) {
|
if entry.Dir || !IsNote(entry.Path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p := filepath.Join(v.root, filepath.FromSlash(e.Path))
|
before, _, err := v.Read(entry.Path)
|
||||||
before, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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
|
return changes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) {
|
func movedPath(p, from, to string, directory bool) string {
|
||||||
rel, err := filepath.Rel(filepath.FromSlash(path.Dir(note)), filepath.FromSlash(to))
|
if p == from {
|
||||||
if err != nil {
|
return to
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
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
|
var out []byte
|
||||||
last := 0
|
last := 0
|
||||||
for _, span := range spans {
|
for _, span := range importLinkSpans(content) {
|
||||||
if span.start < last {
|
if span.start < last {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
target := string(content[span.start:span.end])
|
target, u, ok := LocalTarget(oldNote, string(content[span.start:span.end]))
|
||||||
if span.html {
|
if !ok {
|
||||||
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 {
|
|
||||||
continue
|
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))
|
replacement := EscapeLinkPath(filepath.ToSlash(rel))
|
||||||
if u.RawQuery != "" {
|
if u.RawQuery != "" {
|
||||||
replacement += "?" + u.RawQuery
|
replacement += "?" + u.RawQuery
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-29
@@ -440,13 +440,10 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 图片改名影响引用它的笔记:先读取全部待改正文,失败时不能先移动图片。
|
// 笔记、目录和图片的移动都可能改变跨文件相对链接。
|
||||||
var links []imageLinkChange
|
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "")
|
||||||
if isImage {
|
if err != nil {
|
||||||
links, err = v.imageLinkChanges(from, to)
|
return false, err
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -456,19 +453,13 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
|||||||
}
|
}
|
||||||
assetsMoved := false
|
assetsMoved := false
|
||||||
writtenLinks := 0
|
writtenLinks := 0
|
||||||
var originalNote []byte
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rewritten {
|
|
||||||
if rollback := atomicWrite(dst, originalNote); rollback != nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf("回滚笔记正文失败: %w", rollback))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, change := range links[:writtenLinks] {
|
for _, change := range links[:writtenLinks] {
|
||||||
if rollback := atomicWrite(change.path, change.before); rollback != nil {
|
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 {
|
if assetsMoved {
|
||||||
@@ -485,27 +476,16 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
writtenLinks++
|
writtenLinks++
|
||||||
|
if change.path == dst || info.IsDir() && strings.HasPrefix(change.path, dst+string(filepath.Separator)) {
|
||||||
|
rewritten = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if srcAssets != "" {
|
if srcAssets != "" {
|
||||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||||
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
||||||
}
|
}
|
||||||
assetsMoved = true
|
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 {
|
if commit != nil {
|
||||||
err = commit()
|
err = commit()
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { metadata } from './metadata'
|
||||||
|
import { openReference, ws } from './workspace'
|
||||||
|
const incoming = computed(() => metadata.notes.flatMap(note => note.links.filter(link => link.target === ws.active).map(link => ({ ...link, path: note.path, title: note.title }))))
|
||||||
|
const note = computed(() => metadata.notes.find(note => note.path === ws.active))
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="rm-note-relations" aria-label="笔记关联">
|
||||||
|
<div v-if="note?.tags.length" class="rm-note-tags"><span v-for="tag in note.tags" :key="tag">#{{ tag }}</span></div>
|
||||||
|
<h3>反向链接 <small>{{ incoming.length }}</small></h3>
|
||||||
|
<p v-if="metadata.error" role="alert">{{ metadata.error }}</p>
|
||||||
|
<p v-else-if="!metadata.ready">关系索引准备中…</p>
|
||||||
|
<button v-for="link in incoming" :key="link.path + ':' + link.line" @click="openReference(link.path, link.line)"><strong>{{ link.title }}</strong><small>{{ link.path }} · 第 {{ link.line }} 行</small></button>
|
||||||
|
<p v-if="metadata.ready && !incoming.length">暂无其他笔记链接到这里</p>
|
||||||
|
<p v-for="warning in note?.warnings" :key="warning" role="alert">{{ warning }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.rm-note-relations { padding: 20px 16px; border-top: 1px solid var(--rm-divider); overflow: auto; max-height: 40%; }
|
||||||
|
h3 { font-size: 11px; margin: 0 0 12px; } h3 small { color: var(--rm-text-muted); margin-left: 6px; }
|
||||||
|
p { font-size: 10px; color: var(--rm-text-muted); line-height: 1.8; }
|
||||||
|
button { display: flex; flex-direction: column; gap: 5px; width: 100%; text-align: left; padding: 9px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
|
||||||
|
button:hover { background: var(--rm-hover); } strong { font-size: 11px; font-weight: 500; } small { font-size: 9px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
|
||||||
|
.rm-note-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .rm-note-tags span { font-size: 9px; padding: 4px 7px; border-radius: 5px; background: var(--rm-selection); color: var(--rm-accent); }
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import type { EditorState } from '@codemirror/state'
|
|||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
|
import NoteRelations from './NoteRelations.vue'
|
||||||
import { activeTab, editorView, onEditorUpdate, ws } from './workspace'
|
import { activeTab, editorView, onEditorUpdate, ws } from './workspace'
|
||||||
|
|
||||||
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
|
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
|
||||||
@@ -102,6 +103,7 @@ const minLevel = () => Math.min(...headings.value.map((h) => h.level))
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="rm-tree-empty">没有标题</p>
|
<p v-else class="rm-tree-empty">没有标题</p>
|
||||||
|
<NoteRelations />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,6 +14,16 @@ export interface ImportResult {
|
|||||||
warnings: string[]
|
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 {
|
export interface Theme {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -145,6 +155,7 @@ export const api = {
|
|||||||
|
|
||||||
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
|
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,
|
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,
|
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步
|
// 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步
|
||||||
|
|
||||||
export interface VaultEvent {
|
export interface VaultEvent {
|
||||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins'
|
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
|
||||||
path?: string
|
path?: string
|
||||||
from?: string
|
from?: string
|
||||||
rev?: string
|
rev?: string
|
||||||
|
|||||||
@@ -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 : '关系索引读取失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { createEditorState, insertImageFiles, pasteImages, languageCompartment,
|
|||||||
import { handleShortcut, registerCommand } from './commands'
|
import { handleShortcut, registerCommand } from './commands'
|
||||||
import { api, ApiError, encodePath, type Entry } from './api'
|
import { api, ApiError, encodePath, type Entry } from './api'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
|
import { refreshMetadata } from './metadata'
|
||||||
import { connectEvents, type VaultEvent } from './events'
|
import { connectEvents, type VaultEvent } from './events'
|
||||||
import { basename, dirname, joinPath, isImage, NOTE_EXT, resolveAssetUrl } from './paths'
|
import { basename, dirname, joinPath, isImage, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||||
import { loadSettings, settings } from './settings'
|
import { loadSettings, settings } from './settings'
|
||||||
@@ -201,6 +202,7 @@ function buildState(path: string, doc: string): EditorState {
|
|||||||
spellcheck: settings.spellcheck,
|
spellcheck: settings.spellcheck,
|
||||||
tabSize: settings.tabSize,
|
tabSize: settings.tabSize,
|
||||||
sourceMode: ws.sourceMode,
|
sourceMode: ws.sourceMode,
|
||||||
|
openLink: (href) => { void openMarkdownLink(ref.path, href) },
|
||||||
resolveImage: (src) => {
|
resolveImage: (src) => {
|
||||||
const url = resolveAssetUrl(ref.path, src)
|
const url = resolveAssetUrl(ref.path, src)
|
||||||
const entry = ws.entries.find((e) => url === '/api/v1/raw/' + encodePath(e.path))
|
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<FileResult> {
|
|||||||
return operationFailed(`“${basename(to)}”已存在`)
|
return operationFailed(`“${basename(to)}”已存在`)
|
||||||
}
|
}
|
||||||
const result = await withOp<FileResult>(async () => {
|
const result = await withOp<FileResult>(async () => {
|
||||||
// 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。
|
// 移动会更新仓库中的相对链接,先保存全部已打开笔记,沿用版本冲突保护。
|
||||||
const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir)
|
const impacted = () => ws.tabs.map((t) => t.path)
|
||||||
const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from)
|
|
||||||
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
|
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
|
||||||
if (busy()) {
|
if (busy()) {
|
||||||
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
||||||
@@ -792,7 +793,7 @@ export async function move(from: string, to: string): Promise<FileResult> {
|
|||||||
paused.forEach((s) => s.paused--)
|
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)
|
const tab = tabOf(p)
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
if (tab.dirty) {
|
if (tab.dirty) {
|
||||||
@@ -877,7 +878,8 @@ async function syncOpenTab(tab: Tab) {
|
|||||||
|
|
||||||
async function handleEvents(evs: VaultEvent[]) {
|
async function handleEvents(evs: VaultEvent[]) {
|
||||||
for (const ev of evs) {
|
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 loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
||||||
void refreshPlugins()
|
void refreshPlugins()
|
||||||
}
|
}
|
||||||
@@ -916,6 +918,7 @@ async function handleEvents(evs: VaultEvent[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resync() {
|
async function resync() {
|
||||||
|
void refreshMetadata()
|
||||||
await refreshTree()
|
await refreshTree()
|
||||||
await Promise.all(ws.tabs.map(syncOpenTab))
|
await Promise.all(ws.tabs.map(syncOpenTab))
|
||||||
pluginEvents.dispatchEvent(new CustomEvent('resync'))
|
pluginEvents.dispatchEvent(new CustomEvent('resync'))
|
||||||
@@ -1128,3 +1131,43 @@ export function stopWorkspace() {
|
|||||||
disconnect?.()
|
disconnect?.()
|
||||||
disconnect = undefined
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { renderedBlocks } from './blocks'
|
|||||||
import { codeBlockHighlight } from './codeblocks'
|
import { codeBlockHighlight } from './codeblocks'
|
||||||
import { editorFocus } from './focus'
|
import { editorFocus } from './focus'
|
||||||
import { defaultEditorKeys } from './shortcuts'
|
import { defaultEditorKeys } from './shortcuts'
|
||||||
import { livePreview, resolveImageUrl } from './livepreview'
|
import { livePreview, resolveImageUrl, openLink } from './livepreview'
|
||||||
import { tableEditing } from './table'
|
import { tableEditing } from './table'
|
||||||
import { markdownTheme } from './theme'
|
import { markdownTheme } from './theme'
|
||||||
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../plugins/registry'
|
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../plugins/registry'
|
||||||
@@ -40,6 +40,7 @@ export interface EditorStateOptions {
|
|||||||
sourceMode?: boolean
|
sourceMode?: boolean
|
||||||
shortcuts?: KeyBinding[]
|
shortcuts?: KeyBinding[]
|
||||||
resolveImage?: (src: string) => string
|
resolveImage?: (src: string) => string
|
||||||
|
openLink?: (href: string) => void
|
||||||
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
||||||
onChange?: (update: ViewUpdate) => void
|
onChange?: (update: ViewUpdate) => void
|
||||||
extensions?: Extension[]
|
extensions?: Extension[]
|
||||||
@@ -70,6 +71,7 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
|||||||
]),
|
]),
|
||||||
EditorView.contentAttributes.of({ autocapitalize: 'sentences' }),
|
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.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
||||||
if (opts.onChange) {
|
if (opts.onChange) {
|
||||||
const onChange = opts.onChange
|
const onChange = opts.onChange
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin
|
|||||||
combine: (values) => values[0] ?? ((src) => src),
|
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<null>()
|
export const refreshImages = StateEffect.define<null>()
|
||||||
|
|
||||||
@@ -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 === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
|
||||||
if (node.name === 'Link' || node.name === 'Autolink') {
|
if (node.name === 'Link' || node.name === 'Autolink') {
|
||||||
const url = node.getChild('URL')
|
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
|
return null
|
||||||
@@ -352,7 +368,9 @@ const livePreviewPlugin = ViewPlugin.fromClass(
|
|||||||
const href = pos === null ? null : linkAt(view.state, pos)
|
const href = pos === null ? null : linkAt(view.state, pos)
|
||||||
if (!href) return false
|
if (!href) return false
|
||||||
e.preventDefault()
|
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
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user