feat(ui): redesign public reading and complete glass workspace
Add scoped public navigation, reading progress, focus and print controls without loading the editor. Polish responsive settings, graph and media layouts. Correct share expiry units, preserve math and code during link maintenance, and normalize Markdown destinations for templates and note navigation. Verify browser flows and document the new tools.
This commit is contained in:
1 parent
91a359c2b7
commit
4d1339851b
28 files changed
+472
-66
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+25
-4
@@ -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*<?)` + target)
|
||||
repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/")
|
||||
|
||||
return transformMarkdownLines(content, refDef, func(line, _ []byte, _ int) []byte {
|
||||
line = refDef.ReplaceAll(line, repl)
|
||||
return transformOutsideCode(line, func(text []byte, _ int) []byte { return inline.ReplaceAll(text, repl) })
|
||||
excluded := nonProseRanges(parseMetadata(content))
|
||||
replace := func(pattern *regexp.Regexp, source []byte, offset int) []byte {
|
||||
var out []byte
|
||||
last := 0
|
||||
for _, match := range pattern.FindAllSubmatchIndex(source, -1) {
|
||||
skip := false
|
||||
for _, region := range excluded {
|
||||
if offset+match[0] >= 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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) <img src=\"assets/fake.png\">\n$$\n\n脚注[^a]\n\n[^a]: \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 + $\n\n$$\n<img src=\"a.assets/fake.png\">\n$$\n\n\n"
|
||||
got := string(RewriteAssetLinks([]byte(content), "a", "b"))
|
||||
if !strings.Contains(got, "$x + $") || !strings.Contains(got, "src=\"a.assets/fake.png\"") || !strings.Contains(got, "") {
|
||||
t.Fatalf("公式示例被修改:%s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user