From 1310f8102ecb086a1e7f51e3e7b374156294226d Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 12:04:53 +0100 Subject: [PATCH] feat(workspace): add hashtag indexing, tag pages and bullet group view --- .../src/features/workspace/WorkspaceView.tsx | 354 +++++++++++++++++- frontend/src/lib/api.ts | 23 ++ frontend/src/lib/types.ts | 28 ++ internal/api/server.go | 27 ++ internal/api/server_test.go | 92 +++++ internal/domain/models.go | 28 ++ internal/storage/sqlite.go | 248 +++++++++++- internal/storage/sqlite_test.go | 104 +++++ 8 files changed, 893 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 4f5b93f..62b16b2 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -46,8 +46,9 @@ import { Edit3, Share, Share2, + Hash, } from 'lucide-react'; -import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types'; +import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types'; import { api, getToken, triggerHaptic } from '../../lib/api'; interface WorkspaceViewProps { @@ -91,8 +92,12 @@ export const WorkspaceView: React.FC = ({ initialDocId, onStatsChange, }) => { - // Top Workspace Tab Mode: 'editor' vs 'todos' (Global To-Do Rollup Dashboard) - const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos'>('editor'); + // Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tag' + const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tag'>('editor'); + const [selectedTag, setSelectedTag] = useState(null); + const [tagDetails, setTagDetails] = useState(null); + const [workspaceTags, setWorkspaceTags] = useState([]); + const [isLoadingTag, setIsLoadingTag] = useState(false); const [docs, setDocs] = useState([]); const [selectedDoc, setSelectedDoc] = useState(null); @@ -281,6 +286,10 @@ export const WorkspaceView: React.FC = ({ : prev ); onStatsChange(); + api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); + if (selectedTag) { + api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {}); + } } catch (e: any) { if (e.message && e.message.includes('423')) { api.getDocLock(docToSave.id).then((res) => { @@ -343,12 +352,14 @@ export const WorkspaceView: React.FC = ({ const loadDocs = async (selectId?: string | null) => { try { - const [list, serverFolders] = await Promise.all([ + const [list, serverFolders, tags] = await Promise.all([ api.getDocuments(), api.getFolders(), + api.getWorkspaceTags().catch(() => []), ]); setDocs(list); setCustomFolders(serverFolders); + setWorkspaceTags(tags); if (selectId) { const found = list.find((d) => d.id === selectId); if (found) { @@ -367,6 +378,21 @@ export const WorkspaceView: React.FC = ({ } }; + const openTagPage = async (tag: string) => { + const clean = tag.replace(/^#/, '').toLowerCase(); + setSelectedTag(clean); + setWorkspaceMode('tag'); + setIsLoadingTag(true); + try { + const details = await api.getTagDetails(clean); + setTagDetails(details); + } catch (err) { + console.error('Failed to load tag details', err); + } finally { + setIsLoadingTag(false); + } + }; + useEffect(() => { loadDocs(initialDocId); }, [initialDocId]); @@ -1397,9 +1423,9 @@ export const WorkspaceView: React.FC = ({ URL.revokeObjectURL(url); }; - // Helper to render text with clickable Wiki-Links - const renderTextWithWikiLinks = (text: string) => { - const parts = text.split(/(\[\[.*?\]\])/g); + // Helper to render text with clickable Wiki-Links and Hashtags + const renderFormattedText = (text: string) => { + const parts = text.split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); return parts.map((part, i) => { if (part.startsWith('[[') && part.endsWith(']]')) { const linkTitle = part.slice(2, -2).trim(); @@ -1412,6 +1438,7 @@ export const WorkspaceView: React.FC = ({ if (target) { setSelectedDoc(target); setZoomedBlockId(null); + setWorkspaceMode('editor'); } }} className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${ @@ -1427,6 +1454,23 @@ export const WorkspaceView: React.FC = ({ ); } + if (part.startsWith('#') && part.length > 1) { + const tagWord = part.slice(1); + return ( + { + e.stopPropagation(); + openTagPage(tagWord); + }} + className="inline-flex items-center gap-0.5 px-1.5 py-0.2 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors" + title={`Öffne Hashtag-Seite: #${tagWord}`} + > + + {tagWord} + + ); + } return {part}; }); }; @@ -1496,6 +1540,29 @@ export const WorkspaceView: React.FC = ({ setSelectedDoc(updatedDoc); } + // Also update tagDetails if currently viewing tag page + setTagDetails((prev) => { + if (!prev) return prev; + return { + ...prev, + documents: prev.documents.map((dg) => { + if (dg.docId !== docId) return dg; + return { + ...dg, + matchedBlocks: dg.matchedBlocks.map((mb) => ({ + rootBlock: + mb.rootBlock.id === blockId + ? { ...mb.rootBlock, checked: !mb.rootBlock.checked } + : mb.rootBlock, + children: mb.children.map((c) => + c.id === blockId ? { ...c, checked: !c.checked } : c + ), + })), + }; + }), + }; + }); + // Persist immediately to backend & disk await api.updateDocument(docId, { blocks: updatedBlocks, @@ -1823,6 +1890,26 @@ export const WorkspaceView: React.FC = ({ {allWorkspaceTodos.filter((t) => !t.block.checked).length} + + {selectedTag && ( + + )}
@@ -2047,6 +2134,37 @@ export const WorkspaceView: React.FC = ({ {rootTree.map(renderFolder)} {rootDocs.map((doc) => renderDocItem(doc, 0))} + + {/* Workspace Hashtags Section */} + {workspaceTags.length > 0 && !searchQuery && ( +
+
+
+ + Themen & Hashtags ({workspaceTags.length}) +
+
+
+ {workspaceTags.map((wt) => ( + + ))} +
+
+ )} + {filteredDocs.length === 0 && searchQuery && (
Keine Dateien gefunden für "{searchQuery}" @@ -2430,6 +2548,11 @@ export const WorkspaceView: React.FC = ({ )}
+ {(block.content.includes('[[') || block.content.includes('#')) && ( +
+ {renderFormattedText(block.content)} +
+ )} {/* Progress bar for child todos if any exist */} {(() => { @@ -2631,9 +2754,9 @@ export const WorkspaceView: React.FC = ({ isReadOnly ? 'cursor-default' : '' }`} /> - {block.content.includes('[[') && ( -
- {renderTextWithWikiLinks(block.content)} + {(block.content.includes('[[') || block.content.includes('#')) && ( +
+ {renderFormattedText(block.content)}
)}
@@ -2898,6 +3021,217 @@ export const WorkspaceView: React.FC = ({
)} + {/* ===================================================================== */} + {/* VIEW MODE 3: HASHTAG / THEMEN-SEITE (LOGSEQ STYLE) */} + {/* ===================================================================== */} + {workspaceMode === 'tag' && ( +
+ {/* Tag Page Header */} +
+
+
+ +
+
+
+ +
+
+

+ #{selectedTag} +

+

+ {isLoadingTag + ? 'Wird geladen…' + : `${tagDetails?.totalMatches || 0} ${ + tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte' + } in ${tagDetails?.documents.length || 0} ${ + tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen' + }`} +

+
+
+
+ + {/* Other tags pills for fast hopping */} +
+ {workspaceTags + .filter((t) => t.tag !== selectedTag) + .slice(0, 6) + .map((t) => ( + + ))} +
+
+
+ + {/* Matched Documents List */} + {isLoadingTag ? ( +
+ + Hashtag-Verknüpfungen werden geladen… +
+ ) : !tagDetails || tagDetails.documents.length === 0 ? ( +
+ +

Keine Bullet Points mit #{selectedTag} gefunden

+

+ Füge einfach in irgendeiner Notiz #{selectedTag} in einen Bullet Point ein. +

+
+ ) : ( +
+ {tagDetails.documents.map((docGroup) => ( +
+ {/* Document Header */} +
+
+ {docGroup.icon || '📄'} + {docGroup.title || 'Unbenannt'} + {docGroup.folderPath && ( + + + {docGroup.folderPath} + + )} + + • {docGroup.matchedBlocks.length}{' '} + {docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'} + +
+ + +
+ + {/* Matched Bullet Points and their children */} +
+ {docGroup.matchedBlocks.map((mb, idx) => ( +
+ {/* Root Bullet Point */} +
+ {mb.rootBlock.type === 'todo' ? ( + handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)} + className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0" + /> + ) : ( +
+
+
+ )} + +
{ + const found = docs.find((d) => d.id === docGroup.docId); + if (found) { + setSelectedDoc(found); + setZoomedBlockId(mb.rootBlock.id); + setWorkspaceMode('editor'); + } + }} + className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${ + mb.rootBlock.checked ? 'line-through text-faint' : 'text-main' + }`} + title="Klick: Zu diesem Bullet Point in der Notiz zoomen" + > + {renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')} +
+
+ + {/* Children / Sub-bullets */} + {mb.children.length > 0 && ( +
+ {mb.children.map((child) => { + const indentLevel = Math.max( + 0, + (child.level || 0) - (mb.rootBlock.level || 0) - 1 + ); + return ( +
+ {child.type === 'todo' ? ( + + handleToggleRollupTodo(docGroup.docId, child.id) + } + className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0" + /> + ) : ( + + )} +
{ + const found = docs.find((d) => d.id === docGroup.docId); + if (found) { + setSelectedDoc(found); + setZoomedBlockId(child.id); + setWorkspaceMode('editor'); + } + }} + className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${ + child.checked ? 'line-through text-faint' : 'text-muted' + }`} + title="Klick: In Notiz fokussieren" + > + {renderFormattedText(child.content || '')} +
+
+ ); + })} +
+ )} +
+ ))} +
+
+ ))} +
+ )} +
+ )} + {/* ===================================================================== */} {/* DOCUMENT SHARING MODAL */} {/* ===================================================================== */} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 32d4d19..cc7fbfe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,6 +17,8 @@ import { TaskList, Thought, SubTask, + TagDetails, + TagSummary, User, UserInvite, WSMessage, @@ -388,6 +390,27 @@ export const api = { ); }, + // --- WORKSPACE TAGS --- + async getWorkspaceTags(): Promise { + const res = await request('/api/v1/workspace/tags'); + return ensureArray(res); + }, + + async getTagDetails(tag: string): Promise { + const clean = encodeURIComponent(tag.replace(/^#/, '')); + const res = await request(`/api/v1/workspace/tags/${clean}`); + return { + ...res, + documents: ensureArray(res.documents).map((doc) => ({ + ...doc, + matchedBlocks: ensureArray(doc.matchedBlocks).map((mb) => ({ + rootBlock: mb.rootBlock, + children: ensureArray(mb.children), + })), + })), + }; + }, + // --- WORKSPACE DOCUMENTS --- async getDocuments(): Promise { const res = await request('/api/v1/documents'); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ea5ebc5..1ab2c1f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -170,6 +170,34 @@ export interface Document { sharedWith?: DocShare[]; } +export interface TagSummary { + tag: string; + count: number; +} + +export interface MatchedTagBlock { + rootBlock: DocumentBlock; + children: DocumentBlock[]; +} + +export interface TagDocumentGroup { + docId: string; + title: string; + icon: string; + folderPath: string; + updatedAt: string; + isOwner: boolean; + permission: 'owner' | 'write' | 'read'; + ownerName?: string; + matchedBlocks: MatchedTagBlock[]; +} + +export interface TagDetails { + tag: string; + documents: TagDocumentGroup[]; + totalMatches: number; +} + export interface SpaceMember { spaceId: string; userId: string; diff --git a/internal/api/server.go b/internal/api/server.go index a41baa8..5afebda 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 568ca63..d8020d2 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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) + } +} diff --git a/internal/domain/models.go b/internal/domain/models.go index 5967949..fc1ea09 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index dffca39..d3f0389 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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) diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 33d454f..b55bab4 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +}