feat(workspace): add hashtag indexing, tag pages and bullet group view
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s

This commit is contained in:
sandrodach committed 2026-10-25 12:04:53 +01:00
1 parent c4b2a00348
commit 1310f8102e
8 files changed
+893 -11

No files matched your search

+27
View File
@@ -100,6 +100,10 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/folders", s.handleCreateFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder)
// Workspace Tags & Pages
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
@@ -871,6 +875,29 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
})
}
// --- WORKSPACE TAGS & PAGES ---
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tags, err := s.store.GetWorkspaceTags(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tags)
}
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
details, err := s.store.GetTagDetails(userID, tag)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, details)
}
// --- DOCUMENT SHARING & COLLABORATION ---
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
+92
View File
@@ -619,3 +619,95 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestWorkspaceTagsAPI(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
// Create document with hashtags
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Planung #Sprint12", Level: 0},
{ID: "b2", Type: "bullet", Content: "Subtask 1", Level: 1},
}
_, err := store.CreateDocument(user.ID, "", "Sprint Plan", "🚀", "", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. GET /api/v1/workspace/tags
req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
}
var tagList []domain.TagSummary
_ = json.Unmarshal(rec.Body.Bytes(), &tagList)
if len(tagList) != 1 || tagList[0].Tag != "sprint12" {
t.Fatalf("expected tag 'sprint12', got %+v", tagList)
}
// 2. GET /api/v1/workspace/tags/Sprint12
req = httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags/Sprint12", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
}
var details domain.TagDetails
_ = json.Unmarshal(rec.Body.Bytes(), &details)
if details.Tag != "sprint12" || details.TotalMatches != 1 || len(details.Documents) != 1 {
t.Fatalf("unexpected tag details: %+v", details)
}
matched := details.Documents[0].MatchedBlocks[0]
if matched.RootBlock.ID != "b1" || len(matched.Children) != 1 || matched.Children[0].ID != "b2" {
t.Fatalf("unexpected matched blocks or children: %+v", matched)
}
// 3. Folder endpoints test
// Create folder
createFolderBody := []byte(`{"path":"TestOrdner"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders", bytes.NewReader(createFolderBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/folders, got %d: %s", rec.Code, rec.Body.String())
}
// Get folders
req = httptest.NewRequest(http.MethodGet, "/api/v1/folders", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/folders, got %d", rec.Code)
}
var folders []string
_ = json.Unmarshal(rec.Body.Bytes(), &folders)
foundFolder := false
for _, f := range folders {
if f == "TestOrdner" {
foundFolder = true
break
}
}
if !foundFolder {
t.Fatalf("expected TestOrdner in folders list, got %v", folders)
}
// Delete folder
req = httptest.NewRequest(http.MethodDelete, "/api/v1/folders?path=TestOrdner", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code)
}
}
+28
View File
@@ -262,6 +262,34 @@ type Document struct {
SharedWith []DocShare `json:"sharedWith,omitempty"`
}
type TagSummary struct {
Tag string `json:"tag"`
Count int `json:"count"`
}
type MatchedTagBlock struct {
RootBlock DocumentBlock `json:"rootBlock"`
Children []DocumentBlock `json:"children"`
}
type TagDocumentGroup struct {
DocID string `json:"docId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
UpdatedAt string `json:"updatedAt"`
IsOwner bool `json:"isOwner"`
Permission string `json:"permission"`
OwnerName string `json:"ownerName,omitempty"`
MatchedBlocks []MatchedTagBlock `json:"matchedBlocks"`
}
type TagDetails struct {
Tag string `json:"tag"`
Documents []TagDocumentGroup `json:"documents"`
TotalMatches int `json:"totalMatches"`
}
type SpaceMember struct {
SpaceID string `json:"spaceId"`
UserID string `json:"userId"`
+247 -1
View File
@@ -231,6 +231,22 @@ func (s *Store) migrate() error {
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
CREATE TABLE IF NOT EXISTS doc_block_tags (
id TEXT PRIMARY KEY,
tag TEXT NOT NULL,
user_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
block_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(doc_id, block_id, tag),
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
@@ -325,6 +341,13 @@ func (s *Store) migrate() error {
('smtp_from', ''),
('smtp_tls', 'true')`)
// Reindex block tags if table is newly added or empty
var tagCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
if tagCount == 0 {
_ = s.reindexAllDocBlockTags()
}
return nil
}
@@ -1184,6 +1207,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
}
s.saveDocumentToDisk(doc)
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
return doc, nil
}
@@ -1417,6 +1441,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
return nil, err
}
s.saveDocumentToDisk(doc)
if blocks != nil {
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
}
return doc, nil
}
@@ -1432,10 +1459,228 @@ func (s *Store) DeleteDocument(userID, docID string) error {
if doc != nil {
s.removeDocumentFromDisk(doc)
}
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
return err
}
func (s *Store) reindexAllDocBlockTags() error {
rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`)
if err != nil {
return err
}
defer rows.Close()
type docInfo struct {
id, userID, blocksJSON string
}
var docs []docInfo
for rows.Next() {
var d docInfo
if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil {
docs = append(docs, d)
}
}
_ = rows.Close()
for _, d := range docs {
var blocks []domain.DocumentBlock
if err := json.Unmarshal([]byte(d.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
_ = s.indexDocBlockTags(d.id, d.userID, blocks)
}
}
return nil
}
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error {
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
if len(blocks) == 0 {
return nil
}
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
now := domain.NowISO()
for _, b := range blocks {
if b.Content == "" {
continue
}
tags := domain.ExtractTags(b.Content)
for _, tag := range tags {
tagID := domain.NewID("dbtag")
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
}
}
return nil
}
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
rows, err := s.db.Query(
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
FROM doc_block_tags t
JOIN documents d ON t.doc_id = d.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
WHERE (d.user_id = ? OR s.user_id = ?)
AND d.is_archived = 0
GROUP BY t.tag
ORDER BY count DESC, t.tag ASC`,
userID, userID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]domain.TagSummary, 0)
for rows.Next() {
var ts domain.TagSummary
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
results = append(results, ts)
}
}
return results, nil
}
func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
if cleanTag == "" {
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
}
rows, err := s.db.Query(
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, '') as shared_permission
FROM doc_block_tags t
JOIN documents d ON t.doc_id = d.id
LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
WHERE (d.user_id = ? OR s.user_id = ?)
AND t.tag = ?
AND d.is_archived = 0
ORDER BY d.updated_at DESC`,
userID, userID, userID, cleanTag,
)
if err != nil {
return nil, err
}
defer rows.Close()
type rawMatch struct {
blockID string
docID string
title string
icon string
folderPath string
blocksJSON string
updatedAt string
docUserID string
ownerName string
sharedPermission string
}
var rawMatches []rawMatch
for rows.Next() {
var rm rawMatch
if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil {
rawMatches = append(rawMatches, rm)
}
}
_ = rows.Close()
// Group rawMatches by docID
docMap := make(map[string][]rawMatch)
var docOrder []string
for _, rm := range rawMatches {
if _, exists := docMap[rm.docID]; !exists {
docOrder = append(docOrder, rm.docID)
}
docMap[rm.docID] = append(docMap[rm.docID], rm)
}
totalMatches := 0
docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder))
for _, docID := range docOrder {
matches := docMap[docID]
first := matches[0]
var blocks []domain.DocumentBlock
_ = json.Unmarshal([]byte(first.blocksJSON), &blocks)
blockIdxMap := make(map[string]int, len(blocks))
for i, b := range blocks {
blockIdxMap[b.ID] = i
}
matchedBlocks := make([]domain.MatchedTagBlock, 0)
processedBlockIDs := make(map[string]bool)
for _, m := range matches {
if processedBlockIDs[m.blockID] {
continue
}
idx, ok := blockIdxMap[m.blockID]
if !ok {
continue
}
rootBlock := blocks[idx]
processedBlockIDs[rootBlock.ID] = true
children := make([]domain.DocumentBlock, 0)
rootLevel := rootBlock.Level
for i := idx + 1; i < len(blocks); i++ {
if blocks[i].Level > rootLevel {
children = append(children, blocks[i])
processedBlockIDs[blocks[i].ID] = true
} else {
break
}
}
matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{
RootBlock: rootBlock,
Children: children,
})
totalMatches++
}
if len(matchedBlocks) == 0 {
continue
}
isOwner := first.docUserID == userID
permission := "read"
if isOwner {
permission = "owner"
} else if first.sharedPermission != "" {
permission = first.sharedPermission
}
docGroups = append(docGroups, domain.TagDocumentGroup{
DocID: first.docID,
Title: first.title,
Icon: first.icon,
FolderPath: first.folderPath,
UpdatedAt: first.updatedAt,
IsOwner: isOwner,
Permission: permission,
OwnerName: first.ownerName,
MatchedBlocks: matchedBlocks,
})
}
return &domain.TagDetails{
Tag: cleanTag,
Documents: docGroups,
TotalMatches: totalMatches,
}, nil
}
func (s *Store) GetFolders(userID string) ([]string, error) {
folderSet := make(map[string]bool)
@@ -1549,8 +1794,9 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
}
_ = rows.Close()
// Delete from SQLite, pins, shares
// Delete from SQLite, pins, shares, tags
for _, id := range deletedDocIDs {
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID)
+104
View File
@@ -398,3 +398,107 @@ func TestFolderManagementAndDeletion(t *testing.T) {
t.Fatalf("expected directory %s to be deleted on disk", diskFolder)
}
}
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "tags_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user, err := store.CreateUser("taguser@example.com", "hash", "Tag User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// Create document with nested bullet points and hashtags
blocks := []domain.DocumentBlock{
{ID: "b0", Type: "bullet", Content: "Einleitung ohne Tag", Level: 0},
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Wocheneinkauf", Level: 0},
{ID: "b2", Type: "bullet", Content: "Milch 3.8%", Level: 1},
{ID: "b3", Type: "bullet", Content: "Gouda alt", Level: 1},
{ID: "b4", Type: "bullet", Content: "Zusatzinfo: Aus Weidemilch", Level: 2},
{ID: "b5", Type: "bullet", Content: "Anderes Thema ohne Bezug", Level: 0},
}
doc, err := store.CreateDocument(user.ID, "", "Einkaufszettel", "🛒", "Haushalt", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. Check GetWorkspaceTags
tags, err := store.GetWorkspaceTags(user.ID)
if err != nil {
t.Fatalf("GetWorkspaceTags failed: %v", err)
}
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %d (%v)", len(tags), tags)
}
tagMap := make(map[string]int)
for _, tg := range tags {
tagMap[tg.Tag] = tg.Count
}
if tagMap["kühlregal"] != 1 || tagMap["wocheneinkauf"] != 1 {
t.Fatalf("unexpected tag counts: %v", tagMap)
}
// 2. Check GetTagDetails for #Kühlregal
details, err := store.GetTagDetails(user.ID, "#Kühlregal")
if err != nil {
t.Fatalf("GetTagDetails failed: %v", err)
}
if details.Tag != "kühlregal" {
t.Fatalf("expected tag 'kühlregal', got %s", details.Tag)
}
if details.TotalMatches != 1 {
t.Fatalf("expected 1 match, got %d", details.TotalMatches)
}
if len(details.Documents) != 1 {
t.Fatalf("expected 1 document, got %d", len(details.Documents))
}
docGroup := details.Documents[0]
if docGroup.DocID != doc.ID || docGroup.Title != "Einkaufszettel" {
t.Fatalf("unexpected doc group: %+v", docGroup)
}
if len(docGroup.MatchedBlocks) != 1 {
t.Fatalf("expected 1 matched block, got %d", len(docGroup.MatchedBlocks))
}
matched := docGroup.MatchedBlocks[0]
if matched.RootBlock.ID != "b1" {
t.Fatalf("expected root block b1, got %s", matched.RootBlock.ID)
}
// Children should be b2, b3, b4 (all subsequent blocks with Level > 0 until b5 where Level == 0)
if len(matched.Children) != 3 {
t.Fatalf("expected 3 children (b2, b3, b4), got %d (%v)", len(matched.Children), matched.Children)
}
if matched.Children[0].ID != "b2" || matched.Children[1].ID != "b3" || matched.Children[2].ID != "b4" {
t.Fatalf("unexpected children IDs: %v", matched.Children)
}
// 3. Update document: remove #Wocheneinkauf, add #Vorrat
updatedBlocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0},
}
_, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil)
if err != nil {
t.Fatalf("UpdateDocument failed: %v", err)
}
tagsAfter, err := store.GetWorkspaceTags(user.ID)
if err != nil {
t.Fatalf("GetWorkspaceTags after update failed: %v", err)
}
tagMapAfter := make(map[string]int)
for _, tg := range tagsAfter {
tagMapAfter[tg.Tag] = tg.Count
}
if tagMapAfter["wocheneinkauf"] != 0 {
t.Fatalf("expected wocheneinkauf to be removed, got %v", tagMapAfter)
}
if tagMapAfter["vorrat"] != 1 || tagMapAfter["kühlregal"] != 1 {
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter)
}
}