feat(workspace): synchronize folders with server and add folder deletion
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s

This commit is contained in:
sandrodach committed 2026-10-25 11:50:34 +01:00
1 parent 1be0909344
commit c4b2a00348
5 files changed
+382 -49

No files matched your search

+61 -1
View File
@@ -95,7 +95,11 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
// Workspace Documents
// Workspace Documents & Folders
r.Get("/api/v1/folders", s.handleGetFolders)
r.Post("/api/v1/folders", s.handleCreateFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder)
r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
@@ -811,6 +815,62 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
cleanPath, err := s.store.CreateFolder(userID, req.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
}
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderPath := r.URL.Query().Get("path")
if folderPath == "" {
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
return
}
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, docID := range deletedDocIDs {
s.wsHub.ReleaseDocLock(docID, userID, true)
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": true,
"deletedDocIds": deletedDocIDs,
})
}
// --- DOCUMENT SHARING & COLLABORATION ---
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
+130
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
@@ -1435,6 +1436,135 @@ func (s *Store) DeleteDocument(userID, docID string) error {
return err
}
func (s *Store) GetFolders(userID string) ([]string, error) {
folderSet := make(map[string]bool)
// 1. Collect from SQLite documents (owned by user or shared with user)
rows, err := s.db.Query(
`SELECT DISTINCT folder_path FROM documents
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
AND is_archived = 0 AND folder_path != ''`,
userID, userID,
)
if err == nil {
for rows.Next() {
var fp string
if err := rows.Scan(&fp); err == nil && fp != "" {
clean := filepath.ToSlash(filepath.Clean(fp))
parts := strings.Split(clean, "/")
var cur string
for _, p := range parts {
if p != "" && p != "." {
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
folderSet[cur] = true
}
}
}
}
_ = rows.Close()
}
// 2. Scan physical directories on disk in docDir/userID
if s.docDir != "" {
userDir := filepath.Join(s.docDir, userID)
if fi, err := os.Stat(userDir); err == nil && fi.IsDir() {
_ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error {
if err != nil || !info.IsDir() {
return nil
}
if path == userDir {
return nil
}
rel, err := filepath.Rel(userDir, path)
if err == nil && rel != "" && rel != "." {
clean := filepath.ToSlash(filepath.Clean(rel))
parts := strings.Split(clean, "/")
var cur string
for _, p := range parts {
if p != "" && p != "." {
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
folderSet[cur] = true
}
}
}
return nil
})
}
}
result := make([]string, 0, len(folderSet))
for f := range folderSet {
result = append(result, f)
}
sort.Strings(result)
return result, nil
}
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return "", fmt.Errorf("ungültiger Ordnerpfad")
}
if s.docDir != "" {
targetDir := filepath.Join(s.docDir, userID, clean)
if err := os.MkdirAll(targetDir, 0755); err != nil {
return "", err
}
}
return clean, nil
}
func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return nil, fmt.Errorf("ungültiger Ordnerpfad")
}
// Find all documents in this folder and subfolders
rows, err := s.db.Query(
`SELECT id FROM documents
WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`,
userID, clean, clean+"/%",
)
if err != nil {
return nil, err
}
defer rows.Close()
var deletedDocIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
// Delete from SQLite, pins, shares
for _, id := range deletedDocIDs {
_, _ = 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)
}
// Remove physical folder and subdirectories from disk
if s.docDir != "" {
targetDir := filepath.Join(s.docDir, userID, clean)
_ = os.RemoveAll(targetDir)
}
return deletedDocIDs, nil
}
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
rows, err := s.db.Query(
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
+81
View File
@@ -1,6 +1,7 @@
package storage
import (
"os"
"path/filepath"
"testing"
@@ -317,3 +318,83 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
t.Fatalf("second EnsureDemoAccounts call failed: %v", err)
}
}
func TestFolderManagementAndDeletion(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "folder_test.db")
docDir := filepath.Join(tmpDir, "workspace")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user, err := store.CreateUser("folderuser@example.com", "hash", "Folder User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// 1. Create folders on disk
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
if err != nil || f1 != "Projekte/MindSift" {
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
}
f2, err := store.CreateFolder(user.ID, "Archiv")
if err != nil || f2 != "Archiv" {
t.Fatalf("CreateFolder Archiv failed: %v", err)
}
// 2. Create document in subfolder
doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "📄", "Projekte/MindSift", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
doc2, err := store.CreateDocument(user.ID, "", "SubDoc", "📝", "Projekte/MindSift/Sub", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument 2 failed: %v", err)
}
// 3. GetFolders
folders, err := store.GetFolders(user.ID)
if err != nil {
t.Fatalf("GetFolders failed: %v", err)
}
expected := map[string]bool{
"Archiv": true,
"Projekte": true,
"Projekte/MindSift": true,
"Projekte/MindSift/Sub": true,
}
for _, f := range folders {
delete(expected, f)
}
if len(expected) > 0 {
t.Fatalf("GetFolders missing expected folders: %v (got %v)", expected, folders)
}
// 4. DeleteFolder "Projekte/MindSift" -> should remove doc1 & doc2
deletedDocs, err := store.DeleteFolder(user.ID, "Projekte/MindSift")
if err != nil {
t.Fatalf("DeleteFolder failed: %v", err)
}
if len(deletedDocs) != 2 {
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
}
// Verify docs are gone from DB
_, err = store.GetDocumentByID(user.ID, doc1.ID)
if err == nil {
t.Fatalf("expected doc1 to be deleted")
}
_, err = store.GetDocumentByID(user.ID, doc2.ID)
if err == nil {
t.Fatalf("expected doc2 to be deleted")
}
// Verify disk directory was deleted
diskFolder := filepath.Join(docDir, user.ID, "Projekte/MindSift")
if _, err := os.Stat(diskFolder); !os.IsNotExist(err) {
t.Fatalf("expected directory %s to be deleted on disk", diskFolder)
}
}