- 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>
127 lines
4.8 KiB
Go
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
|
|
}
|