Paket B: Globale Suche mit SQLite FTS5, BM25-Ranking, Zugriffsprüfung und Cmd+K-Palette

This commit is contained in:
sandrodach committed 2026-10-28 23:00:19 +01:00
1 parent 633bd6f1ef
commit 71fa53ead3
10 files changed
+1030 -55

No files matched your search

+2 -5
View File
@@ -2039,15 +2039,12 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q")
thoughts, docs, err := s.store.SearchAll(userID, q)
res, err := s.store.SearchGlobal(userID, q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"thoughts": thoughts,
"documents": docs,
})
writeJSON(w, http.StatusOK, res)
}
// --- CUSTOM THEMES ---
+369
View File
@@ -0,0 +1,369 @@
package storage
import (
"database/sql"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// SearchHit represents a single ranked match across documents, tasks/thoughts, and lists.
type SearchHit struct {
Type string `json:"type"` // "document", "thought", "list_item"
ID string `json:"id"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Score float64 `json:"score,omitempty"`
DocID string `json:"docId,omitempty"`
BlockID string `json:"blockId,omitempty"`
ListID string `json:"listId,omitempty"`
Icon string `json:"icon,omitempty"`
}
// SearchResults encapsulates all results for backwards-compatible API serialization.
type SearchResults struct {
Thoughts []domain.Thought `json:"thoughts"`
Documents []domain.Document `json:"documents"`
ListItems []domain.ListItem `json:"listItems"`
Hits []SearchHit `json:"hits"`
}
var wordTokenRegex = regexp.MustCompile(`[\p{L}\p{N}]+`)
// PrepareFTSQuery extracts valid words from user input and formats them as prefix match tokens.
// E.g. "einkau" -> `"einkau"*`, "Hello World!" -> `"Hello"* "World"*`.
func PrepareFTSQuery(raw string) string {
matches := wordTokenRegex.FindAllString(strings.TrimSpace(raw), -1)
if len(matches) == 0 {
return ""
}
tokens := make([]string, 0, len(matches))
for _, m := range matches {
clean := strings.ReplaceAll(m, `"`, `""`)
tokens = append(tokens, fmt.Sprintf(`"%s"*`, clean))
}
return strings.Join(tokens, " ")
}
// migrateSearchFTS creates the virtual FTS5 tables and synchronization triggers.
func (s *Store) migrateSearchFTS() {
tables := []string{
`CREATE VIRTUAL TABLE IF NOT EXISTS thoughts_fts USING fts5(
thought_id UNINDEXED,
content,
notes,
subtasks,
tokenize='unicode61'
);`,
`CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
doc_id UNINDEXED,
block_id UNINDEXED,
title,
content,
tokenize='unicode61'
);`,
`CREATE VIRTUAL TABLE IF NOT EXISTS list_items_fts USING fts5(
item_id UNINDEXED,
list_id UNINDEXED,
content,
category,
tokenize='unicode61'
);`,
}
for _, stmt := range tables {
if _, err := s.db.Exec(stmt); err != nil {
slog.Error("Failed to create FTS virtual table", "error", err, "statement", stmt)
}
}
triggers := []string{
// Thoughts triggers
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ai AFTER INSERT ON thoughts BEGIN
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
VALUES (new.id, new.content, new.notes, new.subtasks);
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_au AFTER UPDATE ON thoughts BEGIN
DELETE FROM thoughts_fts WHERE thought_id = old.id;
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
VALUES (new.id, new.content, new.notes, new.subtasks);
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ad AFTER DELETE ON thoughts BEGIN
DELETE FROM thoughts_fts WHERE thought_id = old.id;
END;`,
// Documents triggers
`CREATE TRIGGER IF NOT EXISTS trg_documents_ai AFTER INSERT ON documents BEGIN
INSERT INTO documents_fts(doc_id, block_id, title, content)
VALUES (new.id, '', new.title, '');
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_documents_au AFTER UPDATE OF title, is_archived ON documents BEGIN
DELETE FROM documents_fts WHERE doc_id = old.id AND block_id = '';
INSERT INTO documents_fts(doc_id, block_id, title, content)
SELECT new.id, '', new.title, '' WHERE new.is_archived = 0;
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_documents_ad AFTER DELETE ON documents BEGIN
DELETE FROM documents_fts WHERE doc_id = old.id;
END;`,
// Blocks triggers (excluding tombstones/deleted blocks)
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN
INSERT INTO documents_fts(doc_id, block_id, title, content)
SELECT new.document_id, new.id, '', new.content
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_blocks_au AFTER UPDATE OF content, deleted, deleted_at, document_id ON blocks BEGIN
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
INSERT INTO documents_fts(doc_id, block_id, title, content)
SELECT new.document_id, new.id, '', new.content
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
END;`,
// List items triggers
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ai AFTER INSERT ON list_items BEGIN
INSERT INTO list_items_fts(item_id, list_id, content, category)
VALUES (new.id, new.list_id, new.content, new.category);
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_list_items_au AFTER UPDATE ON list_items BEGIN
DELETE FROM list_items_fts WHERE item_id = old.id;
INSERT INTO list_items_fts(item_id, list_id, content, category)
VALUES (new.id, new.list_id, new.content, new.category);
END;`,
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ad AFTER DELETE ON list_items BEGIN
DELETE FROM list_items_fts WHERE item_id = old.id;
END;`,
}
for _, trg := range triggers {
if _, err := s.db.Exec(trg); err != nil {
slog.Error("Failed to create FTS trigger", "error", err, "statement", trg)
}
}
s.backfillSearchFTS()
}
// backfillSearchFTS populates empty FTS indices once on startup.
func (s *Store) backfillSearchFTS() {
var thoughtCount int
_ = s.db.QueryRow(`SELECT count(*) FROM thoughts_fts`).Scan(&thoughtCount)
if thoughtCount == 0 {
_, err := s.db.Exec(`
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
SELECT id, content, notes, subtasks FROM thoughts;
`)
if err != nil {
slog.Error("Failed to backfill thoughts_fts", "error", err)
}
}
var docCount int
_ = s.db.QueryRow(`SELECT count(*) FROM documents_fts`).Scan(&docCount)
if docCount == 0 {
_, err := s.db.Exec(`
INSERT INTO documents_fts(doc_id, block_id, title, content)
SELECT id, '', title, '' FROM documents WHERE is_archived = 0;
INSERT INTO documents_fts(doc_id, block_id, title, content)
SELECT document_id, id, '', content FROM blocks
WHERE deleted = 0 AND (deleted_at = '' OR deleted_at IS NULL);
`)
if err != nil {
slog.Error("Failed to backfill documents_fts", "error", err)
}
}
var listCount int
_ = s.db.QueryRow(`SELECT count(*) FROM list_items_fts`).Scan(&listCount)
if listCount == 0 {
_, err := s.db.Exec(`
INSERT INTO list_items_fts(item_id, list_id, content, category)
SELECT id, list_id, content, category FROM list_items;
`)
if err != nil {
slog.Error("Failed to backfill list_items_fts", "error", err)
}
}
}
// SearchGlobal executes an FTS5 full-text search across documents, thoughts/tasks and list items,
// filtering results strictly to what the user is authorized to view.
func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
ftsQuery := PrepareFTSQuery(query)
res := &SearchResults{
Thoughts: make([]domain.Thought, 0),
Documents: make([]domain.Document, 0),
ListItems: make([]domain.ListItem, 0),
Hits: make([]SearchHit, 0),
}
if ftsQuery == "" {
return res, nil
}
// 1. Search Documents and Blocks
// Weights: 10.0 for title, 1.0 for block content
docRows, err := s.db.Query(`
SELECT f.doc_id, f.block_id,
snippet(documents_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
bm25(documents_fts, 10.0, 1.0) AS score,
d.title, d.icon, d.user_id, d.is_archived
FROM documents_fts f
JOIN documents d ON d.id = f.doc_id
WHERE documents_fts MATCH ? AND d.is_archived = 0
ORDER BY score ASC
LIMIT 40
`, ftsQuery)
if err == nil {
seenDocIDs := make(map[string]bool)
for docRows.Next() {
var docID, blockID, snip, title, icon, ownerID string
var score float64
var isArchived int
if scanErr := docRows.Scan(&docID, &blockID, &snip, &score, &title, &icon, &ownerID, &isArchived); scanErr == nil {
// Access check via DocRole
if s.DocRole(userID, docID) == "" {
continue
}
hitID := docID
if blockID != "" {
hitID = docID + ":" + blockID
}
res.Hits = append(res.Hits, SearchHit{
Type: "document",
ID: hitID,
Title: title,
Snippet: snip,
Score: score,
DocID: docID,
BlockID: blockID,
Icon: icon,
})
if !seenDocIDs[docID] {
seenDocIDs[docID] = true
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
res.Documents = append(res.Documents, *doc)
}
}
}
}
_ = docRows.Close()
}
// 2. Search Thoughts & Tasks
// Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks
thoughtRows, err := s.db.Query(`
SELECT f.thought_id,
snippet(thoughts_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
bm25(thoughts_fts, 5.0, 1.0, 1.0) AS score,
t.id, t.user_id, t.content, t.tags, t.priority, t.source, t.status,
t.is_completed, t.converted_type, t.converted_ref_id, t.due_date,
t.task_list_id, t.kanban_status, t.notes, t.alarm_sent, t.created_at,
t.triaged_at, t.subtasks, t.sort_order
FROM thoughts_fts f
JOIN thoughts t ON t.id = f.thought_id
WHERE thoughts_fts MATCH ?
AND (t.user_id = ? OR t.task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
ORDER BY score ASC
LIMIT 40
`, ftsQuery, userID, userID, userID, userID)
if err == nil {
for thoughtRows.Next() {
var thoughtID, snip string
var score float64
var t domain.Thought
var tagsStr, subtasksStr string
var isCompleted, alarmSent int
var triagedAt, dueDate, convertedType, convertedRefId sql.NullString
if scanErr := thoughtRows.Scan(
&thoughtID, &snip, &score,
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.Status,
&isCompleted, &convertedType, &convertedRefId, &dueDate,
&t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.CreatedAt,
&triagedAt, &subtasksStr, &t.SortOrder,
); scanErr == nil {
t.IsCompleted = isCompleted != 0
t.AlarmSent = alarmSent != 0
t.DueDate = dueDate.String
t.ConvertedType = convertedType.String
t.ConvertedRefID = convertedRefId.String
t.TriagedAt = triagedAt.String
res.Hits = append(res.Hits, SearchHit{
Type: "thought",
ID: t.ID,
Title: t.Content,
Snippet: snip,
Score: score,
})
res.Thoughts = append(res.Thoughts, t)
}
}
_ = thoughtRows.Close()
}
// 3. Search List Items
// Weights: 2.0 for content, 1.0 for category
accessibleLists, _ := s.GetAllListsForUser(userID)
accessibleListIDs := make(map[string]bool, len(accessibleLists))
for _, l := range accessibleLists {
accessibleListIDs[l.ID] = true
}
if len(accessibleListIDs) > 0 {
itemRows, err := s.db.Query(`
SELECT f.item_id, f.list_id,
snippet(list_items_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
bm25(list_items_fts, 2.0, 1.0) AS score,
li.id, li.list_id, li.content, li.category, li.quantity,
li.is_completed, li.completed_by, li.completed_by_name,
li.sort_order, li.created_at, li.completed_at
FROM list_items_fts f
JOIN list_items li ON li.id = f.item_id
WHERE list_items_fts MATCH ?
ORDER BY score ASC
LIMIT 40
`, ftsQuery)
if err == nil {
for itemRows.Next() {
var itemID, listID, snip string
var score float64
var li domain.ListItem
var isCompleted int
if scanErr := itemRows.Scan(
&itemID, &listID, &snip, &score,
&li.ID, &li.ListID, &li.Content, &li.Category, &li.Quantity,
&isCompleted, &li.CompletedBy, &li.CompletedByName,
&li.SortOrder, &li.CreatedAt, &li.CompletedAt,
); scanErr == nil {
if !accessibleListIDs[listID] {
continue
}
li.IsCompleted = isCompleted != 0
res.Hits = append(res.Hits, SearchHit{
Type: "list_item",
ID: li.ID,
Title: li.Content,
Snippet: snip,
Score: score,
ListID: listID,
})
res.ListItems = append(res.ListItems, li)
}
}
_ = itemRows.Close()
}
}
return res, nil
}
+214
View File
@@ -0,0 +1,214 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func setupTestStore(t *testing.T) (*Store, *domain.User, *domain.User) {
t.Helper()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test_search.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
openSharing(t, store)
userA, err := store.CreateUserWithUsername("alice@test.local", "alice", "hash123", "Alice", "member")
if err != nil {
t.Fatalf("CreateUser Alice failed: %v", err)
}
userB, err := store.CreateUserWithUsername("bob@test.local", "bob", "hash123", "Bob", "member")
if err != nil {
t.Fatalf("CreateUser Bob failed: %v", err)
}
return store, userA, userB
}
func TestSearchBlockContent(t *testing.T) {
store, userA, _ := setupTestStore(t)
defer store.Close()
// Create document with block content
doc, err := store.CreateDocument(userA.ID, "", "Rezeptbuch", "📖", "", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
blockID := domain.NewID("blk")
pos := 1.0
_, err = store.db.Exec(`
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
VALUES (?, ?, ?, 0, ?, 'bullet', 0, '')
`, blockID, doc.ID, "Zutaten: 500g Mehl und Hefe für Pizza", pos)
if err != nil {
t.Fatalf("Insert block failed: %v", err)
}
// Search for block content
res, err := store.SearchGlobal(userA.ID, "Hefe")
if err != nil {
t.Fatalf("SearchGlobal failed: %v", err)
}
found := false
for _, hit := range res.Hits {
if hit.DocID == doc.ID && hit.BlockID == blockID {
found = true
if hit.Snippet == "" {
t.Errorf("expected snippet with marked match, got empty string")
}
break
}
}
if !found {
t.Errorf("expected to find hit for block content 'Hefe', hits: %+v", res.Hits)
}
}
func TestSearchTitleRanking(t *testing.T) {
store, userA, _ := setupTestStore(t)
defer store.Close()
// Doc 1 has "Architektur" in block content
docContent, err := store.CreateDocument(userA.ID, "", "Notizen zu Systemen", "📝", "", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
_, err = store.db.Exec(`
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
VALUES (?, ?, 'Hier wird die Architektur im Detail besprochen', 0, 1.0, 'bullet', 0, '')
`, domain.NewID("blk"), docContent.ID)
if err != nil {
t.Fatalf("Insert block failed: %v", err)
}
// Doc 2 has "Architektur" in title
docTitle, err := store.CreateDocument(userA.ID, "", "Architektur Leitfaden", "🏛️", "", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
res, err := store.SearchGlobal(userA.ID, "Architektur")
if err != nil {
t.Fatalf("SearchGlobal failed: %v", err)
}
if len(res.Hits) < 2 {
t.Fatalf("expected at least 2 hits, got %d", len(res.Hits))
}
// First hit must be the document with the title match
if res.Hits[0].DocID != docTitle.ID {
t.Errorf("expected title match (doc %s) to rank first, got doc %s", docTitle.ID, res.Hits[0].DocID)
}
}
func TestSearchPermissionsAndUnshared(t *testing.T) {
store, userA, userB := setupTestStore(t)
defer store.Close()
// Alice creates private doc, private thought, and private list
privateDoc, err := store.CreateDocument(userA.ID, "", "Geheimes Projekt", "🔒", "", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
privateThought, err := store.CreateThought(userA.ID, "Geheimer Gedanke für Alice", "high", "manual", "")
if err != nil {
t.Fatalf("CreateThought failed: %v", err)
}
privateList, err := store.CreateList(userA.ID, "Geheime Einkaufsliste", "🛒", "shopping")
if err != nil {
t.Fatalf("CreateList failed: %v", err)
}
_, err = store.CreateListItem(privateList.ID, "Geheimer Kuchen", "", "", 0, userA.ID)
if err != nil {
t.Fatalf("CreateListItem failed: %v", err)
}
// Bob searches for "Geheim"
resBob, err := store.SearchGlobal(userB.ID, "Geheim")
if err != nil {
t.Fatalf("SearchGlobal for Bob failed: %v", err)
}
if len(resBob.Hits) != 0 || len(resBob.Thoughts) != 0 || len(resBob.Documents) != 0 || len(resBob.ListItems) != 0 {
t.Fatalf("expected Bob to find 0 private hits of Alice, got hits=%+v thoughts=%+v docs=%+v",
resBob.Hits, resBob.Thoughts, resBob.Documents)
}
// Now share privateDoc with Bob
if _, err := store.CreateDocShare(userA.ID, privateDoc.ID, userB.Email, "read"); err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// Bob searches again
resBobShared, err := store.SearchGlobal(userB.ID, "Geheim")
if err != nil {
t.Fatalf("SearchGlobal for Bob after share failed: %v", err)
}
if len(resBobShared.Documents) != 1 || resBobShared.Documents[0].ID != privateDoc.ID {
t.Fatalf("expected Bob to find shared document %s, got %+v", privateDoc.ID, resBobShared.Documents)
}
// Alice searches and finds her own items
resAlice, err := store.SearchGlobal(userA.ID, "Geheim")
if err != nil {
t.Fatalf("SearchGlobal for Alice failed: %v", err)
}
if len(resAlice.Documents) != 1 || len(resAlice.Thoughts) != 1 || len(resAlice.ListItems) != 1 {
t.Fatalf("expected Alice to find her items, got docs=%d thoughts=%d lists=%d",
len(resAlice.Documents), len(resAlice.Thoughts), len(resAlice.ListItems))
}
_ = privateThought
}
func TestSearchPrefixAndSpecialCharacters(t *testing.T) {
store, userA, _ := setupTestStore(t)
defer store.Close()
_, err := store.CreateThought(userA.ID, "Großer Wocheneinkauf im Supermarkt", "normal", "manual", "")
if err != nil {
t.Fatalf("CreateThought failed: %v", err)
}
// Prefix search: "einkau" should find "Wocheneinkauf" or "Einkauf"
// Note: FTS5 prefix matches word prefixes: "Wochen..." vs "einkauf"
_, err = store.CreateThought(userA.ID, "Einkaufszettel schreiben", "normal", "manual", "")
if err != nil {
t.Fatalf("CreateThought failed: %v", err)
}
res, err := store.SearchGlobal(userA.ID, "einkau")
if err != nil {
t.Fatalf("SearchGlobal prefix failed: %v", err)
}
if len(res.Hits) == 0 {
t.Errorf("expected prefix match for 'einkau', got 0 hits")
}
// Special characters should not cause syntax errors
crazyQueries := []string{
`"unclosed quote`,
`test: foo AND OR NOT (bar)^`,
`!@#$%^&*()_+=-[]{}\|;:'",.<>/?`,
`* * *`,
``,
` `,
}
for _, cq := range crazyQueries {
_, err := store.SearchGlobal(userA.ID, cq)
if err != nil {
t.Errorf("query %q caused error: %v", cq, err)
}
}
}
+5 -37
View File
@@ -514,6 +514,7 @@ func (s *Store) migrate() error {
_ = s.reindexAllDocBlockTags()
}
s.migrateAnchorTags()
s.migrateSearchFTS()
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err
@@ -4122,44 +4123,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
}
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
tRows, err := s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
userID, q,
)
thoughts := make([]domain.Thought, 0)
if err == nil {
for tRows.Next() {
if t, err := scanThought(tRows); err == nil {
thoughts = append(thoughts, *t)
}
}
_ = tRows.Close()
res, err := s.SearchGlobal(userID, query)
if err != nil {
return nil, nil, err
}
dRows, err := s.db.Query(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
d.shared_folder_id, d.sub_folder_path
FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,
userID, userID, q, q,
)
docs := make([]domain.Document, 0)
if err == nil {
for dRows.Next() {
if d, err := scanDocument(dRows); err == nil {
docs = append(docs, *d)
}
}
_ = dRows.Close()
}
return thoughts, docs, nil
return res.Thoughts, res.Documents, nil
}
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {