From 39dfe8b285d31fc6eccbd14ab063b161d76651f0 Mon Sep 17 00:00:00 2001 From: eezd Date: Sun, 25 Oct 2026 13:21:09 +0800 Subject: [PATCH] feat(vault): export portable notes with missing attachment reports --- internal/vault/export_notes.go | 129 ++++++++++++++++++++++++++++ internal/vault/export_notes_test.go | 60 +++++++++++++ internal/vault/import.go | 3 + 3 files changed, 192 insertions(+) create mode 100644 internal/vault/export_notes.go create mode 100644 internal/vault/export_notes_test.go diff --git a/internal/vault/export_notes.go b/internal/vault/export_notes.go new file mode 100644 index 0000000..58f5d86 --- /dev/null +++ b/internal/vault/export_notes.go @@ -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 +} diff --git a/internal/vault/export_notes_test.go b/internal/vault/export_notes_test.go new file mode 100644 index 0000000..8f9fbb1 --- /dev/null +++ b/internal/vault/export_notes_test.go @@ -0,0 +1,60 @@ +package vault + +import ( + "archive/zip" + "context" + "io" + "os" + "strings" + "testing" +) + +func TestExportNotesKeepsMarkdownAndReportsMissingAssets(t *testing.T) { + v := newVault(t) + source := "# 原文\n![](assets/a.png)\n![](missing.png)\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) + } +} diff --git a/internal/vault/import.go b/internal/vault/import.go index e7a7e86..b65ea49 100644 --- a/internal/vault/import.go +++ b/internal/vault/import.go @@ -137,6 +137,9 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, ErrTooLarge } for _, f := range z.File { + if f.Name == ".rawmark-export/missing-assets.json" { + continue + } // 本程序笔记导出的缺失附件报告,不作为笔记导入。 if !f.Mode().IsRegular() && !f.Mode().IsDir() { return result, fmt.Errorf("%w:ZIP 不允许符号链接或特殊文件", ErrInvalidImport) }