feat(workspace): add hashtag indexing, tag pages and bullet group view
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
This commit is contained in:
1 parent
c4b2a00348
commit
1310f8102e
8 files changed
+893
-11
No files matched your search
+247
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user