Add tree multiselection, batch actions and Markdown export

This commit is contained in:
eezd committed 2026-10-21 16:10:49 +08:00
1 parent f473ec64b6
commit ffc95d6dbc
14 files changed
+532 -46

No files matched your search

+2 -1
View File
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
{"GET /api/v1/tree", authed, s.tree},
{"POST /api/v1/export", authed, s.export},
{"GET /api/v1/files/{path...}", authed, s.readFile},
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
@@ -182,7 +183,7 @@ func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler {
writeError(w, 401, "API 令牌无效")
return
}
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead {
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
writeError(w, 403, "只读令牌不能修改数据")
return
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"mime"
"net/http"
"os"
"strings"
"time"
)
func (s *Server) export(w http.ResponseWriter, r *http.Request) {
var body struct {
Paths []string `json:"paths"`
}
if !readJSON(w, r, &body) {
return
}
file, name, err := s.vault.Export(r.Context(), body.Paths)
if err != nil {
vaultError(w, r, err)
return
}
defer file.Close()
defer os.Remove(file.Name())
contentType := "application/zip"
if strings.HasSuffix(name, ".md") {
contentType = "text/markdown; charset=utf-8"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
http.ServeContent(w, r, name, time.Time{}, file)
}
+73
View File
@@ -0,0 +1,73 @@
package api
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExportMarkdownAndReferencedAssets(t *testing.T) {
f := newFixture(t)
f.write("资料/文章.md", "# 标题\n![图片](../assets/图.png)\n`![](不存在.png)`\n[其他笔记](未选中.md)")
if err := os.MkdirAll(filepath.Join(f.root, "assets"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(f.root, "assets/图.png"), []byte("图片内容"), 0644); err != nil {
t.Fatal(err)
}
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["资料/文章.md"]}`, nil)
data, _ := io.ReadAll(response.Body)
response.Body.Close()
if response.StatusCode != 200 {
t.Fatalf("导出失败:%d %s", response.StatusCode, data)
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatal(err)
}
files := map[string]string{}
for _, file := range reader.File {
r, err := file.Open()
if err != nil {
t.Fatal(err)
}
content, _ := io.ReadAll(r)
r.Close()
files[file.Name] = string(content)
}
if files["assets/图.png"] != "图片内容" || !strings.Contains(files["资料/文章.md"], "../assets/图.png") || len(files) != 2 {
t.Fatalf("附件或目录不符:%v", files)
}
f.write("单篇.md", "原始 Markdown")
response = f.do(f.admin, "POST", "/api/v1/export", `{"paths":["单篇.md"]}`, nil)
defer response.Body.Close()
data, _ = io.ReadAll(response.Body)
if response.StatusCode != 200 || string(data) != "原始 Markdown" || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/markdown") {
t.Fatalf("单篇导出不符:%d %s", response.StatusCode, data)
}
}
func TestExportRejectsMissingAssetsAndSymlinks(t *testing.T) {
f := newFixture(t)
f.write("文章.md", "![](missing.png)")
for _, body := range []string{`{"paths":["文章.md"]}`, `{"paths":[".rawmark/app.db"]}`, `{"paths":[]}`} {
response := f.do(f.admin, "POST", "/api/v1/export", body, nil)
response.Body.Close()
if response.StatusCode == 200 {
t.Fatalf("不应成功:%s", body)
}
}
f.write("真实.md", "内容")
if err := os.Symlink(filepath.Join(f.root, "真实.md"), filepath.Join(f.root, "别名.md")); err != nil {
t.Fatal(err)
}
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["别名.md"]}`, nil)
response.Body.Close()
if response.StatusCode != 400 {
t.Fatalf("应拒绝符号链接:%d", response.StatusCode)
}
}
+56 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "RawMark API",
"version": "1",
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌只能使用 GET。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
},
"servers": [
{
@@ -2326,6 +2326,61 @@
}
}
}
},
"/export": {
"post": {
"summary": "导出原始 Markdown 和本地附件",
"description": "接收非空路径列表;单篇无附件返回 .md,目录、多选或含附件返回 ZIP。ZIP 保留仓库相对路径;不下载外部资源,不跟随其他未选中笔记。缺失附件或非法路径返回错误。此 POST 接口不修改数据,只读令牌可调用。",
"tags": [
"文件"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"paths"
],
"properties": {
"paths": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "原始 Markdown 或 ZIP 附件",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
},
"text/markdown": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "路径无效"
},
"404": {
"description": "文件或附件不存在"
}
}
}
}
},
"components": {
+197
View File
@@ -0,0 +1,197 @@
package vault
import (
"archive/zip"
"context"
"fmt"
"html"
"io"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。
func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) {
if len(paths) == 0 {
return nil, "", fmt.Errorf("%w:请选择导出内容", ErrInvalidPath)
}
v.mu.Lock()
defer v.mu.Unlock()
files := map[string]bool{}
directory := false
for _, p := range paths {
info, e := v.Stat(p)
if e != nil {
return nil, "", e
}
if e = v.exportPath(p); e != nil {
return nil, "", e
}
if !info.IsDir() {
files[p] = false
continue
}
directory = true
files[p] = true
entries, e := v.TreeAt(p)
if e != nil {
return nil, "", e
}
for _, entry := range entries {
files[path.Join(p, entry.Path)] = entry.Dir
}
}
// 仅收集已选笔记的附件,不追踪链接到的其他笔记。
notes := make([]string, 0)
for p, dir := range files {
if !dir && IsNote(p) {
notes = append(notes, p)
}
}
for _, note := range notes {
if e := ctx.Err(); e != nil {
return nil, "", e
}
content, _, e := v.Read(note)
if e != nil {
return nil, "", e
}
var linkError error
transformMarkdownLines(content, importRef, func(line []byte) []byte {
return transformOutsideCode(line, func(text []byte) []byte {
for _, span := range importLinkSpans(text) {
target := string(text[span.start:span.end])
if span.html {
target = html.UnescapeString(target)
} else {
target = markdownEscape.ReplaceAllString(target, "$1")
}
u, e := url.Parse(target)
if e != nil {
linkError = fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target)
break
}
if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" {
continue
}
rel := path.Clean(path.Join(path.Dir(note), u.Path))
if IsNote(rel) {
continue
}
if e = v.exportPath(rel); e != nil {
linkError = fmt.Errorf("附件 %s:%w", rel, e)
break
}
info, e := v.Stat(rel)
if e != nil {
linkError = fmt.Errorf("附件 %s:%w", rel, e)
break
}
if !info.IsDir() {
files[rel] = false
}
}
return text
})
})
if linkError != nil {
return nil, "", linkError
}
}
tmp, e := os.CreateTemp("", "rawmark-export-*")
if e != nil {
return nil, "", e
}
defer func() {
if err != nil {
tmp.Close()
os.Remove(tmp.Name())
}
}()
if len(paths) == 1 && !directory && len(files) == 1 && IsNote(paths[0]) {
source, _, e := v.OpenRaw(paths[0])
if e != nil {
return nil, "", e
}
_, e = io.Copy(tmp, source)
source.Close()
if e != nil {
return nil, "", e
}
name = path.Base(paths[0])
} else {
writer := zip.NewWriter(tmp)
ordered := make([]string, 0, len(files))
for p := range files {
ordered = append(ordered, p)
}
sort.Strings(ordered)
for _, p := range ordered {
if e := ctx.Err(); e != nil {
return nil, "", e
}
if e := v.exportPath(p); e != nil {
return nil, "", e
}
if files[p] {
if _, e := writer.Create(p + "/"); e != nil {
return nil, "", e
}
continue
}
source, info, e := v.OpenRaw(p)
if e != nil {
return nil, "", e
}
header, e := zip.FileInfoHeader(info)
if e != nil {
source.Close()
return nil, "", e
}
header.Name, header.Method = p, zip.Deflate
dest, e := writer.CreateHeader(header)
if e == nil {
_, e = io.Copy(dest, source)
}
source.Close()
if e != nil {
return nil, "", e
}
}
if e := writer.Close(); e != nil {
return nil, "", e
}
name = "导出.zip"
if len(paths) == 1 {
name = strings.TrimSuffix(path.Base(paths[0]), NoteExt) + ".zip"
}
}
if _, e := tmp.Seek(0, io.SeekStart); e != nil {
return nil, "", e
}
return tmp, name, nil
}
// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。
func (v *Vault) exportPath(rel string) error {
full, err := v.abs(rel)
if err != nil {
return err
}
real, err := filepath.EvalSymlinks(full)
if os.IsNotExist(err) {
return ErrNotFound
}
if err != nil {
return err
}
if real != full {
return ErrInvalidPath
}
return nil
}