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>
This commit is contained in:
1 parent
d6c70d7a8e
commit
e509f685c1
7 files changed
+531
-6
No files matched your search
@@ -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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -362,17 +362,38 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
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<string | null>(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<UserSettingsViewProps> = ({
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Obsidian-compatible Markdown vault */}
|
||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-main">Markdown-Archiv für Obsidian (.zip)</h3>
|
||||
<p className="text-muted text-[11px]">
|
||||
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
|
||||
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
|
||||
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
|
||||
dabei.
|
||||
</p>
|
||||
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportVault}
|
||||
disabled={exportingVault}
|
||||
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1074,6 +1074,17 @@ export const api = {
|
||||
async exportAll(): Promise<any> {
|
||||
return request<any>('/api/v1/export');
|
||||
},
|
||||
|
||||
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
||||
async exportMarkdownVault(): Promise<Blob> {
|
||||
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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user