Add Markdown and ZIP notebook import

This commit is contained in:
eezd committed 2026-10-21 12:56:38 +08:00
1 parent 903a617439
commit 34d2acbbd5
18 files changed
+1166 -16

No files matched your search

+2 -1
View File
@@ -84,6 +84,7 @@ func (s *Server) routes() []route {
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
{"POST /api/v1/move", authed, s.move},
{"POST /api/v1/folders", authed, s.mkdir},
{"POST /api/v1/import", authed, s.importNotes},
{"POST /api/v1/assets", authed, s.uploadAsset},
{"POST /api/v1/assets/import", authed, s.importAsset},
{"GET /api/v1/raw/{path...}", authed, s.raw},
@@ -492,7 +493,7 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.As(err, &conflict):
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
case errors.Is(err, vault.ErrInvalidPath):
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport):
writeError(w, 400, err.Error())
case errors.Is(err, vault.ErrNotFound):
writeError(w, 404, err.Error())
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"errors"
"net/http"
"rawmark/internal/vault"
)
func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
// 总请求上限包含表单开销,文件总量另由 vault 校验。
r.Body = http.MaxBytesReader(w, r.Body, vault.MaxImportBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
var limit *http.MaxBytesError
if errors.As(err, &limit) {
writeError(w, 413, "导入文件总量不能超过 100 MB")
} else {
writeError(w, 400, "无法读取导入表单")
}
return
}
defer r.MultipartForm.RemoveAll()
headers := r.MultipartForm.File["files"]
if len(headers) == 0 || len(headers) > 10_000 {
writeError(w, 400, "请选择 Markdown 文件或一个 ZIP")
return
}
files := make([]vault.ImportFile, 0, len(headers))
for _, h := range headers {
f, err := h.Open()
if err != nil {
internalError(w, r, err)
return
}
defer f.Close()
files = append(files, vault.ImportFile{Name: h.Filename, Reader: f, Size: h.Size})
}
result, err := s.vault.Import(r.Context(), r.FormValue("dir"), r.FormValue("folder"), files)
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, result)
}
+117
View File
@@ -0,0 +1,117 @@
package api
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"rawmark/internal/vault"
)
func TestImportNotesAPI(t *testing.T) {
f := newFixture(t)
send := func(client *http.Client, token, dir, folder, name string, data []byte) *http.Response {
t.Helper()
var body bytes.Buffer
form := multipart.NewWriter(&body)
if err := form.WriteField("dir", dir); err != nil {
t.Fatal(err)
}
if err := form.WriteField("folder", folder); err != nil {
t.Fatal(err)
}
file, err := form.CreateFormFile("files", name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write(data); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("POST", f.url+"/api/v1/import", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", form.FormDataContentType())
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
return res
}
readToken := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"只读","scope":"read"}`, nil)).Token
for _, tc := range []struct {
client *http.Client
token string
status int
}{{newClient(), "", 401}, {newClient(), readToken, 403}} {
res := send(tc.client, tc.token, "", "", "n.md", []byte("正文"))
res.Body.Close()
if res.StatusCode != tc.status {
t.Fatalf("权限状态不符: %d", res.StatusCode)
}
}
res := send(f.admin, "", "", "", "n.md", []byte("正文"))
if res.StatusCode != 201 {
t.Fatalf("导入失败: %d", res.StatusCode)
}
var result vault.ImportResult
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatal(err)
}
res.Body.Close()
if result.Notes != 1 || len(result.Paths) != 1 || result.Paths[0] != "n.md" || result.Warnings == nil {
t.Fatalf("响应不符: %+v", result)
}
res = send(f.admin, "", "", "", "n.md", []byte("覆盖"))
res.Body.Close()
if res.StatusCode != 409 {
t.Fatalf("应拒绝冲突: %d", res.StatusCode)
}
data, err := os.ReadFile(filepath.Join(f.root, "n.md"))
if err != nil || string(data) != "正文" {
t.Fatal("原文被覆盖", err)
}
writeToken := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"写入","scope":"write"}`, nil)).Token
var b bytes.Buffer
zw := zip.NewWriter(&b)
zf, err := zw.Create("子目录/笔记.md")
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(zf, "# ZIP"); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
res = send(newClient(), writeToken, "", "知识库", "export.md.zip", b.Bytes())
res.Body.Close()
if res.StatusCode != 201 {
t.Fatalf("写入令牌导入失败: %d", res.StatusCode)
}
for _, tc := range []struct {
dir, folder, name, data string
status int
}{
{"", "", "x.zip", "invalid", 400}, {"不存在", "", "x.md", "x", 404}, {"n.md", "", "x.md", "x", 400}, {"", "", "x.md", strings.Repeat("a", (20<<20)+1), 413},
} {
res := send(f.admin, "", tc.dir, tc.folder, tc.name, []byte(tc.data))
res.Body.Close()
if res.StatusCode != tc.status {
t.Fatalf("%s 状态不符: %d", tc.name, res.StatusCode)
}
}
}
+96
View File
@@ -2180,6 +2180,102 @@
}
]
}
},
"/import": {
"post": {
"summary": "导入 Markdown 文件或 ZIP 知识库",
"tags": [
"文件"
],
"description": "多选 .md 或单个 ZIP,总上传不超过 100 MB;ZIP 必须指定新建文件夹名称。目标目录必须存在,冲突不覆盖。解压及整理后上限 512 MiB、10,000 项,单篇笔记 20 MiB、附件 50 MiB。Markdown 与 ZIP 文件名须为 UTF-8。ZIP 层级保留,本地图片生成笔记独立附件,原附件保留;外部链接不下载,缺失本地目标以警告返回。",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"files"
],
"properties": {
"dir": {
"type": "string",
"description": "目标目录,空字符串为根目录"
},
"folder": {
"type": "string",
"description": "ZIP 的新建文件夹名称;普通 Markdown 留空"
},
"files": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"responses": {
"201": {
"description": "导入成功",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"paths",
"notes",
"assets",
"warnings"
],
"properties": {
"paths": {
"type": "array",
"items": {
"type": "string"
}
},
"notes": {
"type": "integer"
},
"assets": {
"type": "integer",
"description": "附件文件总数,包含原始附件和独立副本"
},
"warnings": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"400": {
"description": "格式、路径或编码无效"
},
"401": {
"description": "未登录或令牌无效"
},
"403": {
"description": "只读令牌不能导入"
},
"404": {
"description": "目标目录不存在"
},
"409": {
"description": "目标已存在,不覆盖"
},
"413": {
"description": "超过上传、解压、文件数量或单文件大小限制"
}
}
}
}
},
"components": {