Files
MindSift/internal/storage/export_markdown_test.go
T
sandrodachandClaude Opus 5.5 e509f685c1 feat(export): Obsidian-compatible Markdown vault as streamed ZIP
- GET /export/markdown streams the workspace as ZIP (no temp files):
  notes as .md with YAML front matter in the user's folder structure
  (shared notes and mounted folders included, empty folders kept)
- Clean Markdown: top-level headings/dividers/callouts/code as real blocks
  (their children move up a level), lists with tabs, todos as - [ ]
- Aufgaben/<list>.md in Obsidian Tasks format (due date, priority,
  subtasks, notes), Listen/<list>.md for shopping and other lists
- Safe, unique file names; download button in settings (mobile-safe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:21:20 +01:00

127 lines
4.8 KiB
Go

package storage
import (
"archive/zip"
"bytes"
"io"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestBlocksToVaultMarkdown(t *testing.T) {
md := BlocksToVaultMarkdown([]domain.DocumentBlock{
{Type: "h2", Content: "Warum", Level: 0},
{Type: "bullet", Content: "Strom sparen", Level: 1},
{Type: "bullet", Content: "120 € im Jahr", Level: 2},
{Type: "h1", Content: "Umzug", Level: 0},
{Type: "bullet", Content: "Server", Level: 0},
{Type: "todo", Content: "Backup ziehen", Checked: true, Level: 1},
{Type: "bullet", Content: "Zeile 1\nZeile 2", Level: 1},
{Type: "code", Content: "docker compose up -d", Lang: "bash", Level: 2},
{Type: "divider", Level: 0},
{Type: "code", Content: "echo hi", Lang: "sh", Level: 0},
})
want := []string{
// children of a heading start a list at the margin, not an indented code block
"## Warum\n\n- Strom sparen\n\t- 120 € im Jahr\n",
"# Umzug\n\n- Server\n",
"\t- [x] Backup ziehen\n",
"\t- Zeile 1\n\t Zeile 2\n",
"\t\t```bash\n\t\tdocker compose up -d\n\t\t```\n",
"\n---\n\n",
"```sh\necho hi\n```\n",
}
for _, w := range want {
if !strings.Contains(md, w) {
t.Errorf("missing %q in:\n%s", w, md)
}
}
}
func TestWriteMarkdownVault(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "vault.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
if _, err := store.CreateFolder(anna.ID, "Projekte/Leer"); err != nil {
t.Fatalf("CreateFolder: %v", err)
}
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Inhalt", Level: 0}}
// Note tags come from #hashtags in the text
tagged := []domain.DocumentBlock{{ID: "b0", Type: "bullet", Content: "Inhalt #server", Level: 0}}
_, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", tagged, nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", blocks, nil, false)
_, _ = store.CreateDocument(anna.ID, "", "../../etc/passwd", "", "", blocks, nil, false)
shared, _ := store.CreateDocument(ben.ID, "", "Von Ben", "", "", blocks, nil, false)
_, _ = store.CreateDocument(ben.ID, "", "Bens Geheimnis", "", "", blocks, nil, false)
if _, err := store.CreateDocShare(ben.ID, shared.ID, anna.ID, "read"); err != nil {
t.Fatalf("share: %v", err)
}
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
task, _ := store.CreateThoughtWithDetails(anna.ID, "Fenster putzen", "high", "manual", "2026-11-02", tl.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}}
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
_, _ = store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
var buf bytes.Buffer
if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil {
t.Fatalf("WriteMarkdownVault: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("not a zip: %v", err)
}
files := map[string]string{}
for _, f := range zr.File {
for _, part := range strings.Split(f.Name, "/") {
if part == ".." || part == "." || strings.HasPrefix(f.Name, "/") {
t.Errorf("unsafe path in archive: %q", f.Name)
}
}
rc, _ := f.Open()
data, _ := io.ReadAll(rc)
rc.Close()
files[f.Name] = string(data)
}
for _, name := range []string{"Projekte/Leer/", "Projekte/Homelab.md", "Projekte/Homelab (2).md", "Von Ben.md", "Aufgaben/Haushalt.md", "Listen/Einkauf.md"} {
if _, ok := files[name]; !ok {
t.Errorf("missing %q; archive has %v", name, keys(files))
}
}
for name := range files {
if strings.Contains(name, "Geheimnis") {
t.Errorf("Ben's unshared note must not be exported: %q", name)
}
}
tagged1, tagged2 := files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"]
if !strings.Contains(tagged1+tagged2, "---\ntitle: \"Homelab\"\nicon: \"🖥️\"\ntags:\n - \"server\"\n") {
t.Errorf("frontmatter wrong:\n%s\n--- (2):\n%s", files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"])
}
if want := "- [ ] Fenster putzen ⏫ 📅 2026-11-02\n\t- [x] Leiter holen\n"; !strings.Contains(files["Aufgaben/Haushalt.md"], want) {
t.Errorf("task line wrong:\n%s", files["Aufgaben/Haushalt.md"])
}
if !strings.Contains(files["Listen/Einkauf.md"], "- [ ] Milch (2L) #Kühlregal") {
t.Errorf("list item wrong:\n%s", files["Listen/Einkauf.md"])
}
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}