From e509f685c1e49eb073ea2ce63923dcec3ed45a67 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 23:21:20 +0100 Subject: [PATCH] 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/.md in Obsidian Tasks format (due date, priority, subtasks, notes), Listen/.md for shopping and other lists - Safe, unique file names; download button in settings (mobile-safe) Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 1 + Doku/modules/workspace.md | 11 + .../features/settings/UserSettingsView.tsx | 55 ++- frontend/src/lib/api.ts | 11 + internal/api/server.go | 12 + internal/storage/export_markdown.go | 321 ++++++++++++++++++ internal/storage/export_markdown_test.go | 126 +++++++ 7 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 internal/storage/export_markdown.go create mode 100644 internal/storage/export_markdown_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index cb29a6f..b6db0c3 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -31,6 +31,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/stats` | Zähler fürs Dashboard | | GET | `/search?q=` | Suche in eigenen Gedanken und Notizen | | GET | `/export` | Datenexport als JSON | +| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | DELETE | `/themes/{id}` | Eigenes Theme löschen | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 6127a75..e775e8f 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -51,3 +51,14 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*. - Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)). + +## Export als Markdown-Archiv + +Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)). + +- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten. +- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`. +- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“. +- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien. +- Ein Import aus ZIP gibt es noch nicht. + diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index daf5e0a..41ad376 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -362,17 +362,38 @@ export const UserSettingsView: React.FC = ({ setTimeout(() => setCopiedLabel(null), 2000); }; + // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran + const saveBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 10000); + }; + const handleExportData = async () => { const data = await api.exportAll(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8', }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`; - a.click(); - URL.revokeObjectURL(url); + saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`); + }; + + const [exportingVault, setExportingVault] = useState(false); + const [vaultError, setVaultError] = useState(null); + const handleExportVault = async () => { + setExportingVault(true); + setVaultError(null); + try { + saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`); + } catch (err: any) { + setVaultError(err.message || 'Export fehlgeschlagen'); + } finally { + setExportingVault(false); + } }; const baseUrl = window.location.origin; @@ -1230,6 +1251,28 @@ export const UserSettingsView: React.FC = ({ Backup herunterladen + + {/* Obsidian-compatible Markdown vault */} +
+
+

Markdown-Archiv für Obsidian (.zip)

+

+ Alle Notizen als .md-Dateien in deiner Ordnerstruktur (mit Titel, Tags und + Datum im Kopf), Aufgaben je Liste unter Aufgaben/ im Format des + Obsidian-Tasks-Plugins, Listen unter Listen/. Mit dir geteilte Notizen sind + dabei. +

+ {vaultError &&

{vaultError}

} +
+ +
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a51c502..af20c22 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1074,6 +1074,17 @@ export const api = { async exportAll(): Promise { return request('/api/v1/export'); }, + + // Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout + async exportMarkdownVault(): Promise { + const token = getToken(); + const res = await fetch('/api/v1/export/markdown', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + cache: 'no-store', + }); + if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`); + return res.blob(); + }, }; export function getOfflineQueue(): Thought[] { diff --git a/internal/api/server.go b/internal/api/server.go index 620ab93..bd68973 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -109,6 +109,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/stats", s.handleStats) r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/export", s.handleExport) + r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Get("/api/v1/themes", s.handleGetThemes) r.Post("/api/v1/themes", s.handleSaveTheme) r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme) @@ -2117,6 +2118,17 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } +// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files). +func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02"))) + if err := s.store.WriteMarkdownVault(userID, w); err != nil { + // Headers are already sent; the client gets a truncated archive + slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err) + } +} + func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) exportData, err := s.store.ExportUserData(userID) diff --git a/internal/storage/export_markdown.go b/internal/storage/export_markdown.go new file mode 100644 index 0000000..aa5283b --- /dev/null +++ b/internal/storage/export_markdown.go @@ -0,0 +1,321 @@ +package storage + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// WriteMarkdownVault streams the user's workspace as an Obsidian-compatible ZIP straight into w: +// notes as .md files in their folders (as this user sees them, shared ones included), task lists +// under Aufgaben/, shopping and other lists under Listen/. Nothing is buffered on disk. +func (s *Store) WriteMarkdownVault(userID string, w io.Writer) error { + zw := zip.NewWriter(w) + names := newVaultNames() + now := time.Now() + + // Folders first, so empty ones survive the round trip + if folders, err := s.GetFolders(userID); err == nil { + for _, f := range folders { + if dir := names.dir(f); dir != "" { + if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir + "/", Modified: now}); err != nil { + return err + } + } + } + } + + docs, err := s.GetDocuments(userID) + if err != nil { + return err + } + for _, d := range docs { + name := names.file(names.dir(d.FolderPath), d.Title, "Unbenannte Notiz") + body := markdownFrontmatter(map[string]any{ + "title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt, + }) + BlocksToVaultMarkdown(d.Blocks) + if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil { + return err + } + } + + if err := s.writeVaultTasks(zw, userID, names, now); err != nil { + return err + } + if err := s.writeVaultLists(zw, userID, names, now); err != nil { + return err + } + return zw.Close() +} + +func (s *Store) writeVaultTasks(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error { + tasks, err := s.GetTasks(userID, "") + if err != nil { + return err + } + lists, _ := s.GetTaskLists(userID) + byList := make(map[string][]domain.Thought) + for _, t := range tasks { + byList[t.TaskListID] = append(byList[t.TaskListID], t) + } + write := func(title string, tl *domain.TaskList, items []domain.Thought) error { + sort.SliceStable(items, func(i, j int) bool { return items[i].SortOrder < items[j].SortOrder }) + meta := map[string]any{"title": title} + if tl != nil { + meta["icon"], meta["created_at"], meta["updated_at"] = tl.Icon, tl.CreatedAt, tl.UpdatedAt + } + var sb strings.Builder + sb.WriteString(markdownFrontmatter(meta)) + for _, t := range items { + sb.WriteString(taskMarkdownLine(t)) + } + return writeZipFile(zw, names.file("Aufgaben", title, "Aufgaben"), sb.String(), now) + } + for i := range lists { + if err := write(lists[i].Title, &lists[i], byList[lists[i].ID]); err != nil { + return err + } + } + if items := byList[""]; len(items) > 0 { + return write("Ohne Liste", nil, items) + } + return nil +} + +func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error { + lists, err := s.GetAllListsForUser(userID) + if err != nil { + return err + } + for _, l := range lists { + var sb strings.Builder + sb.WriteString(markdownFrontmatter(map[string]any{"title": l.Title, "icon": l.Icon, "created_at": l.CreatedAt, "updated_at": l.UpdatedAt})) + for _, it := range l.Items { + mark := "[ ]" + if it.IsCompleted { + mark = "[x]" + } + line := "- " + mark + " " + oneLine(it.Content) + if it.Quantity != "" { + line += " (" + oneLine(it.Quantity) + ")" + } + if it.Category != "" && !strings.Contains(it.Content, "#") { + line += " #" + strings.ReplaceAll(it.Category, " ", "_") + } + sb.WriteString(line + "\n") + } + if err := writeZipFile(zw, names.file("Listen", l.Title, "Liste"), sb.String(), parseISO(l.UpdatedAt, now)); err != nil { + return err + } + } + return nil +} + +// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority) +// with its subtasks and notes indented below. +func taskMarkdownLine(t domain.Thought) string { + mark := "[ ]" + if t.IsCompleted { + mark = "[x]" + } + line := "- " + mark + " " + oneLine(t.Content) + switch t.Priority { + case "urgent": + line += " 🔺" + case "high": + line += " ⏫" + case "low": + line += " 🔽" + } + if t.DueDate != "" { + line += " 📅 " + t.DueDate + } + var sb strings.Builder + sb.WriteString(line + "\n") + for _, st := range t.Subtasks { + sub := "[ ]" + if st.IsCompleted { + sub = "[x]" + } + sb.WriteString("\t- " + sub + " " + oneLine(st.Title) + "\n") + } + if notes := strings.TrimSpace(t.Notes); notes != "" { + for _, l := range strings.Split(notes, "\n") { + sb.WriteString("\t> " + strings.TrimRight(l, "\r") + "\n") + } + } + return sb.String() +} + +// BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively: +// top-level headings, dividers and callouts as real block elements, everything else as a nested +// list (one tab per level), code as fenced blocks inside the list. +func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string { + var sb strings.Builder + // Children of a top-level heading/callout/code/divider become a list one level up; otherwise their + // tab indent would turn them into an indented code block in Markdown + lift := 0 + for _, b := range blocks { + lvl := b.Level + if lvl <= 0 { + lvl, lift = 0, 0 + } else if lvl -= lift; lvl < 0 { + lvl = 0 + } + indent := strings.Repeat("\t", lvl) + content := strings.ReplaceAll(b.Content, "\r\n", "\n") + // Soft line breaks continue the list item, aligned under its text + cont := func(text, prefix string) string { + return strings.ReplaceAll(text, "\n", "\n"+indent+prefix) + } + + if b.Level <= 0 { + switch b.Type { + case "h1", "h2", "h3": + hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type] + sb.WriteString("\n" + hashes + " " + oneLine(content) + "\n\n") + lift = 1 + continue + case "divider": + sb.WriteString("\n---\n\n") + lift = 1 + continue + case "callout": + sb.WriteString("\n> [!note]\n> " + strings.ReplaceAll(content, "\n", "\n> ") + "\n\n") + lift = 1 + continue + case "code": + sb.WriteString("\n```" + b.Lang + "\n" + content + "\n```\n\n") + lift = 1 + continue + } + } + switch b.Type { + case "code": + // Fenced block nested under the previous list item + sb.WriteString(indent + "```" + b.Lang + "\n" + indent + strings.ReplaceAll(content, "\n", "\n"+indent) + "\n" + indent + "```\n") + case "todo": + mark := "[ ]" + if b.Checked { + mark = "[x]" + } + sb.WriteString(indent + "- " + mark + " " + cont(content, " ") + "\n") + case "h1", "h2", "h3": + hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type] + sb.WriteString(indent + "- " + hashes + " " + oneLine(content) + "\n") + case "divider": + sb.WriteString(indent + "- ---\n") + case "callout": + sb.WriteString(indent + "- > " + cont(content, " > ") + "\n") + default: + if strings.TrimSpace(content) == "" { + sb.WriteString(indent + "-\n") + } else { + sb.WriteString(indent + "- " + cont(content, " ") + "\n") + } + } + } + return strings.TrimLeft(sb.String(), "\n") +} + +// markdownFrontmatter writes YAML front matter; JSON-encoded strings are valid YAML scalars. +func markdownFrontmatter(meta map[string]any) string { + keys := make([]string, 0, len(meta)) + for k := range meta { + keys = append(keys, k) + } + order := map[string]int{"title": 0, "icon": 1, "tags": 2, "created_at": 3, "updated_at": 4} + sort.Slice(keys, func(i, j int) bool { return order[keys[i]] < order[keys[j]] }) + var sb strings.Builder + sb.WriteString("---\n") + for _, k := range keys { + switch v := meta[k].(type) { + case string: + if v != "" { + q, _ := json.Marshal(v) + sb.WriteString(k + ": " + string(q) + "\n") + } + case []string: + if len(v) > 0 { + sb.WriteString(k + ":\n") + for _, t := range v { + q, _ := json.Marshal(t) + sb.WriteString(" - " + string(q) + "\n") + } + } + } + } + sb.WriteString("---\n\n") + return sb.String() +} + +func writeZipFile(zw *zip.Writer, name, body string, modified time.Time) error { + f, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modified}) + if err != nil { + return err + } + _, err = io.WriteString(f, body) + return err +} + +func oneLine(s string) string { + return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ") +} + +func parseISO(v string, fallback time.Time) time.Time { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + return fallback +} + +// vaultNames builds safe, unique file and folder names inside the archive. +type vaultNames struct{ used map[string]bool } + +func newVaultNames() *vaultNames { return &vaultNames{used: make(map[string]bool)} } + +// segment makes one path part safe on Windows, macOS and Linux and never "." or "..". +func segment(s string) string { + s = strings.Map(func(r rune) rune { + if r < 32 || strings.ContainsRune(`/\:*?"<>|`, r) { + return '_' + } + return r + }, strings.TrimSpace(s)) + s = strings.Trim(s, ". ") + if len([]rune(s)) > 100 { + s = string([]rune(s)[:100]) + } + return s +} + +func (n *vaultNames) dir(folderPath string) string { + parts := make([]string, 0) + for _, p := range strings.Split(folderPath, "/") { + if seg := segment(p); seg != "" { + parts = append(parts, seg) + } + } + return path.Join(parts...) +} + +// file returns "dir/title.md", adding " (2)", " (3)" … when the name is already taken. +func (n *vaultNames) file(dir, title, fallback string) string { + base := segment(title) + if base == "" { + base = fallback + } + name := path.Join(dir, base+".md") + for i := 2; n.used[strings.ToLower(name)]; i++ { + name = path.Join(dir, fmt.Sprintf("%s (%d).md", base, i)) + } + n.used[strings.ToLower(name)] = true + return name +} diff --git a/internal/storage/export_markdown_test.go b/internal/storage/export_markdown_test.go new file mode 100644 index 0000000..4e7433d --- /dev/null +++ b/internal/storage/export_markdown_test.go @@ -0,0 +1,126 @@ +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 +}