feat(vault): export portable notes with missing attachment reports
This commit is contained in:
1 parent
f7584d1089
commit
39dfe8b285
3 files changed
+192
No files matched your search
@@ -0,0 +1,129 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MissingAsset struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) ExportNotes(ctx context.Context) (result *os.File, err error) {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
f, err := os.CreateTemp("", "rawmark-notes-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
z := zip.NewWriter(f)
|
||||||
|
missing := []MissingAsset{}
|
||||||
|
err = filepath.WalkDir(v.root, func(full string, d os.DirEntry, e error) error {
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e = ctx.Err(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
rel, e := filepath.Rel(v.root, full)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
for _, p := range strings.Split(rel, "/") {
|
||||||
|
if strings.HasPrefix(p, ".") {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, e := d.Info()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
h, e := zip.FileInfoHeader(info)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
h.Name = rel
|
||||||
|
h.Method = zip.Deflate
|
||||||
|
if d.IsDir() {
|
||||||
|
h.Name += "/"
|
||||||
|
}
|
||||||
|
out, e := z.CreateHeader(h)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
in, e := os.Open(full)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
_, e = io.Copy(out, in)
|
||||||
|
e = errors.Join(e, in.Close())
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if IsNote(rel) {
|
||||||
|
content, _, e := v.Read(rel)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
for _, target := range Analyze(rel, content).Assets {
|
||||||
|
// Analyze 的目标已解析为仓库相对路径,仅检查本地资源。
|
||||||
|
if strings.HasPrefix(target.Target, "/") || strings.Contains(target.Target, "://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, e := v.Stat(target.Target); errors.Is(e, ErrNotFound) {
|
||||||
|
missing = append(missing, MissingAsset{Note: rel, Path: target.Target})
|
||||||
|
} else if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
// 报告放在内部目录,不与用户的普通 Markdown 文件重名;不修改原链接。
|
||||||
|
out, e := z.Create(path.Join(".rawmark-export", "missing-assets.json"))
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if err = json.NewEncoder(out).Encode(missing); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = z.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err = f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportNotesKeepsMarkdownAndReportsMissingAssets(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
source := "# 原文\n\n\n"
|
||||||
|
if _, err := v.Write("note.md", []byte(source), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Write("other.md", []byte("trash"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v.Delete("other.md")
|
||||||
|
v.SaveAsset("note.md", "a.png", strings.NewReader("image"), 50<<20)
|
||||||
|
file, err := v.ExportNotes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(file.Name())
|
||||||
|
defer file.Close()
|
||||||
|
info, _ := file.Stat()
|
||||||
|
z, err := zip.NewReader(file, info.Size())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, f := range z.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, _ := f.Open()
|
||||||
|
body, err := io.ReadAll(r)
|
||||||
|
r.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen[f.Name] = string(body)
|
||||||
|
}
|
||||||
|
if seen["note.md"] != source {
|
||||||
|
t.Fatal("导出改写了正文")
|
||||||
|
}
|
||||||
|
if _, ok := seen["other.md"]; ok {
|
||||||
|
t.Fatal("不应导出回收站")
|
||||||
|
}
|
||||||
|
if !strings.Contains(seen[".rawmark-export/missing-assets.json"], "missing.png") {
|
||||||
|
t.Fatal("未报告缺失附件")
|
||||||
|
}
|
||||||
|
target := newVault(t)
|
||||||
|
if _, err = target.Import(context.Background(), "", "restored", []ImportFile{{Name: "notes.zip", Reader: file, Size: info.Size()}}); err != nil {
|
||||||
|
t.Fatalf("导出包不能导回: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,9 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
|||||||
return result, ErrTooLarge
|
return result, ErrTooLarge
|
||||||
}
|
}
|
||||||
for _, f := range z.File {
|
for _, f := range z.File {
|
||||||
|
if f.Name == ".rawmark-export/missing-assets.json" {
|
||||||
|
continue
|
||||||
|
} // 本程序笔记导出的缺失附件报告,不作为笔记导入。
|
||||||
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
||||||
return result, fmt.Errorf("%w:ZIP 不允许符号链接或特殊文件", ErrInvalidImport)
|
return result, fmt.Errorf("%w:ZIP 不允许符号链接或特殊文件", ErrInvalidImport)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user