From c4b2a00348c700ffa8ef3983e5b950013c9dae96 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 11:50:34 +0100 Subject: [PATCH] feat(workspace): synchronize folders with server and add folder deletion --- .../src/features/workspace/WorkspaceView.tsx | 138 ++++++++++++------ frontend/src/lib/api.ts | 20 +++ internal/api/server.go | 62 +++++++- internal/storage/sqlite.go | 130 +++++++++++++++++ internal/storage/sqlite_test.go | 81 ++++++++++ 5 files changed, 382 insertions(+), 49 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 64e434b..4f5b93f 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -101,15 +101,13 @@ export const WorkspaceView: React.FC = ({ const [showIconPicker, setShowIconPicker] = useState(false); const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false); - // Folder state - const [customFolders, setCustomFolders] = useState(() => { + // Folder state (synchronized with server) + const [customFolders, setCustomFolders] = useState([]); + useEffect(() => { try { - const stored = localStorage.getItem('mindsift_workspace_folders'); - return stored ? JSON.parse(stored) : []; - } catch { - return []; - } - }); + localStorage.removeItem('mindsift_workspace_folders'); + } catch {} + }, []); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [newFolderParent, setNewFolderParent] = useState(null); @@ -344,20 +342,28 @@ export const WorkspaceView: React.FC = ({ }; const loadDocs = async (selectId?: string | null) => { - const list = await api.getDocuments(); - setDocs(list); - if (selectId) { - const found = list.find((d) => d.id === selectId); - if (found) { - setSelectedDoc(found); - return; + try { + const [list, serverFolders] = await Promise.all([ + api.getDocuments(), + api.getFolders(), + ]); + setDocs(list); + setCustomFolders(serverFolders); + if (selectId) { + const found = list.find((d) => d.id === selectId); + if (found) { + setSelectedDoc(found); + return; + } } - } - if (!selectedDoc && list.length > 0) { - setSelectedDoc(list[0]); - } else if (selectedDoc) { - const updated = list.find((d) => d.id === selectedDoc.id); - if (updated) setSelectedDoc(updated); + if (!selectedDoc && list.length > 0) { + setSelectedDoc(list[0]); + } else if (selectedDoc) { + const updated = list.find((d) => d.id === selectedDoc.id); + if (updated) setSelectedDoc(updated); + } + } catch (err) { + console.error('Error loading documents or folders:', err); } }; @@ -504,6 +510,8 @@ export const WorkspaceView: React.FC = ({ } onStatsChange(); } + } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { + api.getFolders().then(setCustomFolders).catch(() => {}); } }; @@ -817,25 +825,13 @@ export const WorkspaceView: React.FC = ({ .catch((err) => console.error('Failed to update doc folder', err)); } - // 2. Update customFolders in state & localStorage - const updatedCustomFolders = customFolders.map((p) => { - if (p === cleanSource) return newFolderPath; - if (p.startsWith(cleanSource + '/')) { - return `${newFolderPath}${p.slice(cleanSource.length)}`; - } - return p; - }); - - if (!updatedCustomFolders.includes(newFolderPath)) { - updatedCustomFolders.push(newFolderPath); - } - const sortedCustom = Array.from(new Set(updatedCustomFolders)).sort((a, b) => - a.localeCompare(b) - ); - setCustomFolders(sortedCustom); - try { - localStorage.setItem('mindsift_workspace_folders', JSON.stringify(sortedCustom)); - } catch {} + // 2. Sync folder structure with server + api + .createFolder(newFolderPath) + .then(() => api.deleteFolder(cleanSource)) + .then(() => api.getFolders()) + .then(setCustomFolders) + .catch((err) => console.error('Error syncing moved folders with server', err)); // Expand the new folder path and its parent setExpandedFolders((prev) => { @@ -881,20 +877,21 @@ export const WorkspaceView: React.FC = ({ } }; - const handleConfirmCreateFolder = () => { + const handleConfirmCreateFolder = async () => { const trimmed = newFolderName.trim(); if (!trimmed) { setIsCreatingFolder(false); return; } const fullPath = newFolderParent ? `${newFolderParent}/${trimmed}` : trimmed; - if (!customFolders.includes(fullPath)) { - const next = [...customFolders, fullPath].sort((a, b) => a.localeCompare(b)); - setCustomFolders(next); - try { - localStorage.setItem('mindsift_workspace_folders', JSON.stringify(next)); - } catch { - // ignore + try { + await api.createFolder(fullPath); + const serverFolders = await api.getFolders(); + setCustomFolders(serverFolders); + } catch (err) { + console.error('Failed to create folder on server', err); + if (!customFolders.includes(fullPath)) { + setCustomFolders((prev) => [...prev, fullPath].sort((a, b) => a.localeCompare(b))); } } setExpandedFolders((prev) => new Set([...prev, fullPath])); @@ -903,6 +900,40 @@ export const WorkspaceView: React.FC = ({ setNewFolderParent(null); }; + const handleDeleteFolder = async (folderPath: string, folderName: string) => { + // Find all documents in this folder and subfolders + const docsInFolder = docs.filter((d) => { + const f = getDocFolder(d); + return f === folderPath || f.startsWith(`${folderPath}/`); + }); + + const confirmMsg = + docsInFolder.length > 0 + ? `Möchtest du den Ordner "${folderName}" wirklich löschen? Alle ${docsInFolder.length} darin enthaltenen Notizen werden unwiderruflich gelöscht!` + : `Möchtest du den leeren Ordner "${folderName}" wirklich löschen?`; + + if (!window.confirm(confirmMsg)) return; + + try { + const res = await api.deleteFolder(folderPath); + const deletedIds = new Set(res.deletedDocIds || docsInFolder.map((d) => d.id)); + + const nextDocs = docs.filter((d) => !deletedIds.has(d.id)); + setDocs(nextDocs); + + if (selectedDoc && deletedIds.has(selectedDoc.id)) { + setSelectedDoc(nextDocs.length > 0 ? nextDocs[0] : null); + } + + const serverFolders = await api.getFolders(); + setCustomFolders(serverFolders); + onStatsChange(); + } catch (err) { + console.error('Failed to delete folder', err); + alert('Fehler beim Löschen des Ordners'); + } + }; + const toggleFolder = (folderPath: string) => { setExpandedFolders((prev) => { const next = new Set(prev); @@ -1688,6 +1719,17 @@ export const WorkspaceView: React.FC = ({ > + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fdd36cf..32d4d19 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -368,6 +368,26 @@ export const api = { await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' }); }, + // --- WORKSPACE FOLDERS --- + async getFolders(): Promise { + const res = await request('/api/v1/folders'); + return ensureArray(res); + }, + + async createFolder(path: string): Promise<{ path: string }> { + return request<{ path: string }>('/api/v1/folders', { + method: 'POST', + body: JSON.stringify({ path }), + }); + }, + + async deleteFolder(path: string): Promise<{ deleted: boolean; deletedDocIds: string[] }> { + return request<{ deleted: boolean; deletedDocIds: string[] }>( + `/api/v1/folders?path=${encodeURIComponent(path)}`, + { method: 'DELETE' } + ); + }, + // --- WORKSPACE DOCUMENTS --- async getDocuments(): Promise { const res = await request('/api/v1/documents'); diff --git a/internal/api/server.go b/internal/api/server.go index e3011c5..a41baa8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) { diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5299047..dffca39 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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 diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 49c2efa..33d454f 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +}