diff --git a/README.md b/README.md index f49c084..205355e 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ docker run -d --name rawmark \ - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 -- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持预览、重命名、移动、替换与移入回收站。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 +- 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 +- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 +- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 +- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 -- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。 +- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。公开阅读页仅提供分享范围内的导航、大纲、阅读进度、复制与打印,不加载编辑器。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量分享、删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 快捷键:设置中支持添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 - 导入:侧栏顶部或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 diff --git a/internal/api/assets.go b/internal/api/assets.go index de7227b..8c3218a 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -1,7 +1,9 @@ package api import ( + "errors" "net/http" + "rawmark/internal/vault" "time" ) @@ -22,12 +24,17 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { return nil, err } scopes := []string{} - now := time.Now().Unix() + now := time.Now().UnixMilli() for _, share := range shares { if share.ExpiresAt != 0 && share.ExpiresAt <= now { continue } for _, item := range share.Items { + if _, err := s.vault.Stat(item.Path); errors.Is(err, vault.ErrNotFound) { + continue + } else if err != nil { + return nil, err + } scopes = append(scopes, item.Path) } } diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go new file mode 100644 index 0000000..53d24f3 --- /dev/null +++ b/internal/api/assets_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "rawmark/internal/store" + "rawmark/internal/vault" + "testing" + "time" +) + +func TestImageCleanupHonorsShareExpiryAndReferences(t *testing.T) { + f := newFixture(t) + f.write("assets/active.png", "active") + f.write("assets/expired.png", "expired") + f.write("assets/used.png", "used") + f.write("note.md", "[image](assets/used.png)") + for _, share := range []store.Share{ + {Token: "active", Items: []store.ShareItem{{ID: 0, Path: "assets/active.png"}}, ExpiresAt: time.Now().Add(time.Hour).UnixMilli()}, + {Token: "expired", Items: []store.ShareItem{{ID: 0, Path: "assets/expired.png"}}, ExpiresAt: time.Now().Add(-time.Hour).UnixMilli()}, + } { + if err := f.store.CreateShare(share); err != nil { + t.Fatal(err) + } + } + if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/expired.png"]}`, nil); code != http.StatusUnauthorized { + t.Fatal(code) + } + result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/active.png","assets/expired.png","assets/used.png"]}`, nil)) + if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/expired.png" || len(result.Skipped) != 2 { + t.Fatalf("引用或分享保护不符:%+v", result) + } + if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized { + t.Fatal(code) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index c894bb9..7058ccf 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -732,7 +732,7 @@ { "name": "note", "in": "query", - "required": true, + "required": false, "description": "所属笔记路径", "schema": { "type": "string" @@ -792,7 +792,8 @@ } } } - } + }, + "description": "note 与 folder 必须且只能指定一个;note 上传到笔记附件目录,folder 上传到目录的 assets 子目录。" } }, "/assets/import": { diff --git a/internal/vault/images.go b/internal/vault/images.go index aa1dacd..1c30f16 100644 --- a/internal/vault/images.go +++ b/internal/vault/images.go @@ -95,7 +95,18 @@ func movedPath(p, from, to string, directory bool) string { func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) { var out []byte last := 0 + excluded := nonProseRanges(parseMetadata(content)) for _, span := range importLinkSpans(content) { + skip := false + for _, region := range excluded { + if span.start >= region[0] && span.start < region[1] { + skip = true + break + } + } + if skip { + continue + } if span.start < last { continue } diff --git a/internal/vault/links.go b/internal/vault/links.go index cd22aeb..d7af993 100644 --- a/internal/vault/links.go +++ b/internal/vault/links.go @@ -7,7 +7,7 @@ import ( ) // RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。 -// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码块和行内代码。 +// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。 func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/") oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/") @@ -16,9 +16,30 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*= region[0] && offset+match[0] < region[1] { + skip = true + break + } + } + if skip { + continue + } + out = append(out, source[last:match[0]]...) + out = pattern.Expand(out, repl, source, match) + last = match[1] + } + return append(out, source[last:]...) + } + return transformMarkdownLines(content, refDef, func(line, _ []byte, offset int) []byte { + line = transformOutsideCode(line, func(text []byte, start int) []byte { return replace(inline, text, offset+start) }) + return replace(refDef, line, offset) }) } diff --git a/internal/vault/metadata.go b/internal/vault/metadata.go index d6609f3..ba2ea68 100644 --- a/internal/vault/metadata.go +++ b/internal/vault/metadata.go @@ -13,7 +13,6 @@ import ( "unicode/utf8" "github.com/yuin/goldmark/v2/ast" - "github.com/yuin/goldmark/v2/parser" "go.yaml.in/yaml/v3" ) @@ -111,7 +110,7 @@ func Analyze(note string, content []byte) Metadata { } } } - root := parser.New().Parse(source) + root := parseMetadata(source) _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil @@ -129,16 +128,16 @@ func Analyze(note string, content []byte) Metadata { } } switch node := n.(type) { - case *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition: + case *ast.AutoLink, *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition: return ast.WalkSkipChildren, nil case *ast.Link: - addRef(node.Destination.Value(source), node.Pos()) + addRef(node.Destination.Str(source), node.Pos()) return ast.WalkSkipChildren, nil case *ast.Image: - addRef(node.Destination.Value(source), node.Pos()) + addRef(node.Destination.Str(source), node.Pos()) return ast.WalkSkipChildren, nil case *ast.Text: - value := node.Value.Value(source) + value := node.Value.Str(source) for _, loc := range inlineTag.FindAllStringIndex(value, -1) { if loc[0] > 0 { prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]]) @@ -152,10 +151,21 @@ func Analyze(note string, content []byte) Metadata { return ast.WalkContinue, nil }) // HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。 + excluded := nonProseRanges(root) for _, span := range importLinkSpans(content) { if !span.html { continue } + skip := false + for _, region := range excluded { + if span.start >= region[0] && span.start < region[1] { + skip = true + break + } + } + if skip { + 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}) diff --git a/internal/vault/metadata_math.go b/internal/vault/metadata_math.go new file mode 100644 index 0000000..edf4d2b --- /dev/null +++ b/internal/vault/metadata_math.go @@ -0,0 +1,104 @@ +package vault + +import ( + "bytes" + "unicode" + "unicode/utf8" + + "github.com/yuin/goldmark/v2/ast" + "github.com/yuin/goldmark/v2/extension" + "github.com/yuin/goldmark/v2/parser" + "github.com/yuin/goldmark/v2/text" + "github.com/yuin/goldmark/v2/util" +) + +// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。 +// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。 +type metadataMath struct{} + +func (metadataMath) Trigger() []byte { return []byte{'$'} } +func (metadataMath) Open(parent ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) { + line, _ := reader.PeekLine() + if parent.Kind() != ast.KindDocument || !bytes.Equal(bytes.TrimSpace(line), []byte("$$")) { + return nil, parser.NoChildren + } + return ast.NewCodeBlock(ast.CodeBlockKindFenced, text.Lines{}), parser.NoChildren +} +func (metadataMath) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State { + line, segment := reader.PeekLine() + reader.AdvanceToEOL() + if bytes.Equal(bytes.TrimSpace(line), []byte("$$")) { + return parser.Close + } + node.(*ast.CodeBlock).Value.AppendSegment(segment) + return parser.Continue | parser.NoChildren +} +func (metadataMath) Close(ast.Node, text.Reader, parser.Context) {} +func (metadataMath) CanInterruptParagraph() bool { return true } +func (metadataMath) CanAcceptIndentedLine() bool { return false } +func (metadataMath) Parse(_ ast.Node, reader text.Reader, _ parser.Context) ast.Node { + line, first := reader.PeekLine() + if len(line) < 2 || line[1] == '$' { + return nil + } + next, _ := utf8.DecodeRune(line[1:]) + if unicode.IsSpace(next) { + return nil + } + savedLine, savedPosition := reader.Position() + reader.Advance(1) + for { + line, segment := reader.PeekLine() + if line == nil { + reader.SetPosition(savedLine, savedPosition) + return nil + } + for i := 0; i < len(line); i++ { + if line[i] == '\\' { + i++ + continue + } + if line[i] != '$' || i == 0 { + continue + } + prev, _ := utf8.DecodeLastRune(line[:i]) + if unicode.IsSpace(prev) { + continue + } + if i+1 < len(line) && line[i+1] >= '0' && line[i+1] <= '9' { + continue + } + reader.Advance(i + 1) + return ast.NewCodeSpan(text.NewSingleLineValueFromIndex(text.NewIndex(first.Start, segment.Start+i+1), nil)) + } + reader.AdvanceLine() + } +} +func parseMetadata(source []byte) ast.Node { + math := metadataMath{} + return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source) +} + +// 返回非正文的字节范围,供无损链接改写器跳过公式中的示例语法。 +func nonProseRanges(root ast.Node) [][2]int { + var ranges [][2]int + _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := node.(type) { + case *ast.CodeSpan: + for _, index := range node.Value.Indices() { + ranges = append(ranges, [2]int{index.Start, index.Stop}) + } + return ast.WalkSkipChildren, nil + case *ast.CodeBlock: + for _, index := range node.Value.Segments() { + ranges = append(ranges, [2]int{index.Start, index.Stop}) + } + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) + return ranges +} diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go index 0281b88..b543a0b 100644 --- a/internal/vault/metadata_test.go +++ b/internal/vault/metadata_test.go @@ -73,3 +73,23 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) { t.Fatal("移动主体未回滚", err) } } + +func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) { + content := "#正文\n\n公式 $x + #1 + [fake](bad.md)$\n\n$$\n#2 [fake](bad2.md) \n$$\n\n脚注[^a]\n\n[^a]: ![图片](assets/foot.png)\n\n\\#escaped https://example.com/?x=#url\n" + m := Analyze("a.md", []byte(content)) + if !reflect.DeepEqual(m.Tags, []string{"正文"}) || len(m.Links) != 0 || len(m.Assets) != 1 || m.Assets[0].Target != "assets/foot.png" { + t.Fatalf("扩展语法误识别:%+v", m) + } + rewritten, err := rewriteMovedLinks([]byte(content), "a.md", "dir/a.md", "a.md", "dir/a.md", false, false) + if err != nil || !strings.Contains(string(rewritten), "[fake](bad.md)") || !strings.Contains(string(rewritten), "[fake](bad2.md)") || !strings.Contains(string(rewritten), "../assets/foot.png") { + t.Fatalf("公式或脚注改写不符:%s %v", rewritten, err) + } +} + +func TestRenamedAssetsPreserveMathExamples(t *testing.T) { + content := "公式 $x + ![](a.assets/fake.png)$\n\n$$\n\n$$\n\n![](a.assets/real.png)\n" + got := string(RewriteAssetLinks([]byte(content), "a", "b")) + if !strings.Contains(got, "$x + ![](a.assets/fake.png)$") || !strings.Contains(got, "src=\"a.assets/fake.png\"") || !strings.Contains(got, "![](b.assets/real.png)") { + t.Fatalf("公式示例被修改:%s", got) + } +} diff --git a/web/src/app/AppHeader.vue b/web/src/app/AppHeader.vue index 16dc12b..537e622 100644 --- a/web/src/app/AppHeader.vue +++ b/web/src/app/AppHeader.vue @@ -7,7 +7,7 @@ import { createNote, ws } from './workspace'
个人知识库 - +
diff --git a/web/src/app/AppearanceSettings.vue b/web/src/app/AppearanceSettings.vue index ebadbf8..682c982 100644 --- a/web/src/app/AppearanceSettings.vue +++ b/web/src/app/AppearanceSettings.vue @@ -75,6 +75,7 @@ async function reset() { diff --git a/web/src/app/EditorPane.vue b/web/src/app/EditorPane.vue index b548114..948d35f 100644 --- a/web/src/app/EditorPane.vue +++ b/web/src/app/EditorPane.vue @@ -48,14 +48,17 @@ function show() { onMounted(() => { view = createEditorView(host.value!, EditorState.create()) attachView(view) + document.fonts.addEventListener('loadingdone', measure) show() }) +function measure() { view.requestMeasure() } onBeforeUnmount(() => { + document.fonts.removeEventListener('loadingdone', measure) if (shown) storeViewState(shown.path, view.state) attachView(null) view.destroy() }) -watch(() => [ws.active, tab.value?.status], show, { flush: 'post' }) +watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => { show(); requestAnimationFrame(() => view.requestMeasure()) }, { flush: 'post' })