feat(workspace): deleted folders go to the trash as a whole, after a confirmation

A deleted personal folder is kept in trashed_folders with its subfolders;
its notes are trashed and tied to it. The trash lists the folder as one
entry with its note count; restoring brings back the folder tree (empty
subfolders too) and all notes, deleting for good, emptying the trash and
the notes trash delay remove it with its notes. Folders in shared folders
keep the old way.

Deleting a folder now asks first, in a small popover at the click
(ConfirmPopover, documented in ui-patterns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 19:36:22 +01:00
1 parent 9dcfa6c27e
commit b26b07cfea
18 files changed
+564 -25

No files matched your search

+32
View File
@@ -63,6 +63,38 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
})
}
// handleRestoreFolder brings a folder from the trash back with its subfolders and notes.
func (s *Server) handleRestoreFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docIDs, err := s.store.RestoreFolder(userID, chi.URLParam(r, "id"))
if err != nil {
writeTrashError(w, err)
return
}
// Everyone with access to one of the notes reloads their tree
users := map[string]bool{userID: true}
for _, docID := range docIDs {
authorized, _ := s.store.GetAuthorizedDocUsers(docID)
for _, uid := range authorized {
users[uid] = true
}
}
for uid := range users {
s.wsHub.BroadcastToUser(uid, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"restored": "folder"})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"restored": true, "restoredDocIds": docIDs})
}
// handlePurgeFolder deletes a folder in the trash with its notes for good.
func (s *Server) handlePurgeFolder(w http.ResponseWriter, r *http.Request) {
docIDs, err := s.store.PurgeFolder(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeTrashError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"deleted": true, "deletedDocIds": docIDs})
}
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
+2
View File
@@ -238,6 +238,8 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/folders", s.handleCreateFolder)
r.Post("/api/v1/folders/move", s.handleMoveFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder)
r.Post("/api/v1/folders/trash/{id}/restore", s.handleRestoreFolder)
r.Delete("/api/v1/folders/trash/{id}", s.handlePurgeFolder)
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder)
+1
View File
@@ -86,6 +86,7 @@ func purgeStmts(section string) []struct {
{`DELETE FROM doc_user_folders WHERE user_id = ?`, 1},
{`DELETE FROM doc_pins WHERE user_id = ?`, 1},
{`DELETE FROM workspace_folders WHERE user_id = ?`, 1},
{`DELETE FROM trashed_folders WHERE user_id = ?`, 1},
{`DELETE FROM tag_pages WHERE user_id = ?`, 1},
}
case SectionLists:
+18
View File
@@ -133,11 +133,13 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
var deletedDocIDs []string
var writes []sqlStmt
seenDocID := make(map[string]bool)
inSharedFolder := false
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
sfID, subPath, isMatch := matchMountPath(clean, mounts)
if isMatch && sfID != "" {
inSharedFolder = true
if subPath == "" {
// Root of a shared folder
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
@@ -201,6 +203,22 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
for _, id := range deletedDocIDs {
writes = append(writes, trashStmts(userID, id)...)
}
// A personal folder goes to the trash as a whole (with its subfolders), so it can come back as it was
if !inSharedFolder {
subfolders := []string{}
if frows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)
AND path LIKE ?`, userID, clean+"/%"); err == nil {
for frows.Next() {
var p string
if frows.Scan(&p) == nil {
subfolders = append(subfolders, p)
}
}
_ = frows.Close()
}
_, folderWrites := trashFolderStmts(userID, clean, subfolders, deletedDocIDs)
writes = append(writes, folderWrites...)
}
// Also clear any personal folder placements for shared notes in this folder
writes = append(writes,
+1
View File
@@ -38,6 +38,7 @@ var migrations = []migration{
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
{16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention},
{17, "OAuth für den MCP-Zugang", (*Store).migrateOAuth},
{18, "Ordner im Papierkorb", (*Store).migrateFolderTrash},
}
func (s *Store) migrate() error {
+7
View File
@@ -207,6 +207,13 @@ func (s *Store) PurgeExpired(now time.Time, allowNever bool) (PurgeResult, error
}
res.Notes = append(res.Notes, id)
}
// Folders in the notes trash: same delay; their notes went above (they were trashed at the same time)
if _, err := s.db.Exec(`DELETE FROM trashed_folders WHERE id IN (
SELECT tf.id FROM trashed_folders tf JOIN users u ON u.id = tf.user_id
WHERE `+daysSQL("u.note_trash_days")+` > 0 AND `+cutoffSQL("tf.trashed_at", daysSQL("u.note_trash_days"))+`)`,
neverAs(allowNever, d.NoteTrash), since, since, sqlNow(now), neverAs(allowNever, d.NoteTrash)); err != nil {
return res, err
}
// Ticked-off list items: the list owner's delay, from ticking them off
rows, err = s.db.Query(`SELECT li.id, li.list_id FROM list_items li JOIN lists l ON l.id = li.list_id JOIN users u ON u.id = l.user_id
+3 -3
View File
@@ -429,10 +429,10 @@ func TestFolderManagementAndDeletion(t *testing.T) {
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
}
// The notes are in the trash now, no longer in the tree
// The folder is in the trash with both notes, which are no longer in the tree
trash, _ := store.GetTrash(user.ID)
if len(trash) != 2 {
t.Fatalf("expected doc1 and doc2 in the trash, got %v", trash)
if len(trash) != 1 || trash[0].Kind != "folder" || trash[0].NoteCount != 2 {
t.Fatalf("expected the folder with doc1 and doc2 in the trash, got %v", trash)
}
docs, _ := store.GetDocuments(user.ID)
for _, d := range docs {
+159 -5
View File
@@ -2,7 +2,10 @@ package storage
import (
"database/sql"
"encoding/json"
"errors"
"path"
"sort"
"github.com/sandrodach/mindsift/internal/domain"
)
@@ -18,14 +21,38 @@ var ErrNotInTrash = errors.New("Notiz nicht im Papierkorb")
// ErrDocInTrash refuses changes to a trashed note; it has to be restored first.
var ErrDocInTrash = errors.New("Die Notiz liegt im Papierkorb. Stelle sie zuerst wieder her.")
// TrashedDocument is one entry of the trash list.
// Deleted personal folders go to the trash as a whole: trashed_folders keeps the folder and its
// subfolders, and its notes are trashed with trash_folder_id pointing there. The trash lists the folder
// as one entry; restoring it brings back the folder tree (empty subfolders too) and all of its notes.
// Folders inside shared folders keep the old way (their notes go to the trash one by one).
// TrashedDocument is one entry of the trash list: a note, or a deleted folder (Kind "folder", ID of the
// trashed folder, Title its name, FolderPath the folder it was in, NoteCount the notes it holds).
type TrashedDocument struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
DeletedAt string `json:"deletedAt"`
IsOwner bool `json:"isOwner"`
NoteCount int `json:"noteCount,omitempty"`
}
func (s *Store) migrateFolderTrash() error {
if err := s.addColumnIfMissing("documents", "trash_folder_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS trashed_folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
path TEXT NOT NULL,
folders TEXT NOT NULL DEFAULT '[]',
trashed_at TEXT NOT NULL
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_folders_user ON trashed_folders(user_id)`),
)
}
const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)`
@@ -41,12 +68,15 @@ func (s *Store) trashDocument(userID, docID string) error {
return s.execInTx(trashStmts(userID, docID)...)
}
// GetTrash lists the notes in the user's trash, newest deletion first.
// GetTrash lists the notes and folders in the user's trash, newest deletion first. Notes of a trashed
// folder are part of the folder's entry.
func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) {
rows, err := s.db.Query(`SELECT d.id, d.title, d.icon, d.folder_path, d.shared_folder_id, d.sub_folder_path, d.archived_at, d.user_id,
COALESCE(wsf.name, '')
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
WHERE d.`+trashScope+` ORDER BY d.archived_at DESC`, userID, userID)
WHERE d.`+trashScope+`
AND NOT EXISTS (SELECT 1 FROM trashed_folders tf WHERE tf.id = d.trash_folder_id)
ORDER BY d.archived_at DESC`, userID, userID)
if err != nil {
return nil, err
}
@@ -64,12 +94,128 @@ func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) {
t.FolderPath += "/" + subPath
}
}
t.Kind = "note"
t.IsOwner = ownerID == userID
out = append(out, t)
}
_ = rows.Close()
frows, err := s.db.Query(`SELECT tf.id, tf.path, tf.trashed_at,
(SELECT COUNT(*) FROM documents d WHERE d.trash_folder_id = tf.id AND d.is_archived = 1)
FROM trashed_folders tf WHERE tf.user_id = ?`, userID)
if err != nil {
return nil, err
}
defer frows.Close()
for frows.Next() {
var t TrashedDocument
var p string
if frows.Scan(&t.ID, &p, &t.DeletedAt, &t.NoteCount) != nil {
continue
}
t.Kind = "folder"
t.Title = path.Base(p)
if parent := path.Dir(p); parent != "." {
t.FolderPath = parent
}
t.IsOwner = true
out = append(out, t)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].DeletedAt > out[j].DeletedAt })
return out, nil
}
// trashFolderStmts records a deleted personal folder (with its subfolders) in the trash and ties its
// notes to it; the notes themselves are trashed by the caller.
func trashFolderStmts(userID, folderPath string, subfolders, docIDs []string) (string, []sqlStmt) {
id := domain.NewID("tfld")
paths, _ := json.Marshal(subfolders)
out := []sqlStmt{stmt(`INSERT INTO trashed_folders (id, user_id, path, folders, trashed_at) VALUES (?, ?, ?, ?, ?)`,
id, userID, folderPath, string(paths), domain.NowISO())}
for _, docID := range docIDs {
out = append(out, stmt(`UPDATE documents SET trash_folder_id = ? WHERE id = ?`, id, docID))
}
return id, out
}
type trashedFolder struct {
path string
folders []string
}
func (s *Store) trashedFolder(userID, id string) (*trashedFolder, error) {
var f trashedFolder
var raw string
err := s.db.QueryRow(`SELECT path, folders FROM trashed_folders WHERE id = ? AND user_id = ?`, id, userID).Scan(&f.path, &raw)
if err == sql.ErrNoRows {
return nil, ErrNotInTrash
}
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(raw), &f.folders)
return &f, nil
}
func (s *Store) trashedFolderDocs(id string) ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM documents WHERE trash_folder_id = ? AND is_archived = 1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var docID string
if rows.Scan(&docID) == nil {
ids = append(ids, docID)
}
}
return ids, nil
}
// RestoreFolder brings a trashed folder back: the folder and all of its subfolders (empty ones too),
// then its notes. Returns the restored note IDs.
func (s *Store) RestoreFolder(userID, id string) ([]string, error) {
f, err := s.trashedFolder(userID, id)
if err != nil {
return nil, err
}
for _, p := range append([]string{f.path}, f.folders...) {
if _, err := s.CreateFolder(userID, p); err != nil {
return nil, err
}
}
docIDs, err := s.trashedFolderDocs(id)
if err != nil {
return nil, err
}
for _, docID := range docIDs {
if err := s.RestoreDocument(userID, docID); err != nil {
return nil, err
}
}
_, err = s.db.Exec(`DELETE FROM trashed_folders WHERE id = ?`, id)
return docIDs, err
}
// PurgeFolder deletes a trashed folder and its notes for good. Returns the deleted note IDs.
func (s *Store) PurgeFolder(userID, id string) ([]string, error) {
if _, err := s.trashedFolder(userID, id); err != nil {
return nil, err
}
docIDs, err := s.trashedFolderDocs(id)
if err != nil {
return nil, err
}
for _, docID := range docIDs {
if err := s.hardDeleteDocument(docID); err != nil {
return nil, err
}
}
_, err = s.db.Exec(`DELETE FROM trashed_folders WHERE id = ?`, id)
return docIDs, err
}
func (s *Store) trashedDocOwner(userID, docID string) (string, error) {
var owner string
err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner)
@@ -101,7 +247,7 @@ func (s *Store) RestoreDocument(userID, docID string) error {
return err
}
}
_, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID)
_, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '', trash_folder_id = '' WHERE id = ?`, docID)
return err
}
@@ -113,7 +259,7 @@ func (s *Store) PurgeDocument(userID, docID string) error {
return s.hardDeleteDocument(docID)
}
// EmptyTrash deletes every note in the user's trash for good and returns their IDs.
// EmptyTrash deletes every note and folder in the user's trash for good and returns the note IDs.
func (s *Store) EmptyTrash(userID string) ([]string, error) {
items, err := s.GetTrash(userID)
if err != nil {
@@ -121,6 +267,14 @@ func (s *Store) EmptyTrash(userID string) ([]string, error) {
}
ids := make([]string, 0, len(items))
for _, t := range items {
if t.Kind == "folder" {
docIDs, err := s.PurgeFolder(userID, t.ID)
ids = append(ids, docIDs...)
if err != nil {
return ids, err
}
continue
}
if err := s.hardDeleteDocument(t.ID); err != nil {
return ids, err
}
+102
View File
@@ -3,6 +3,7 @@ package storage
import (
"path/filepath"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
@@ -91,3 +92,104 @@ func TestTrashHidesRestoresAndPurgesNotes(t *testing.T) {
t.Errorf("purge must remove the note and its shares")
}
}
// A deleted personal folder goes to the trash as one entry with its notes and subfolders (empty ones too)
// and comes back as it was; deleting it for good or letting it expire removes it with its notes.
func TestDeletedFolderGoesToTrashAsAWhole(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "foldertrash.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
for _, p := range []string{"Projekte", "Projekte/Stadt", "Projekte/Leer", "Privat"} {
if _, err := store.CreateFolder(anna.ID, p); err != nil {
t.Fatal(err)
}
}
top, _ := store.CreateDocument(anna.ID, "", "Übersicht", "", "Projekte", nil, nil, false)
inner, _ := store.CreateDocument(anna.ID, "", "Verkehr", "", "Projekte/Stadt", nil, nil, false)
loose, _ := store.CreateDocument(anna.ID, "", "Einzeln", "", "Privat", nil, nil, false)
_ = store.DeleteDocument(anna.ID, loose.ID)
if _, err := store.DeleteFolder(anna.ID, "Projekte"); err != nil {
t.Fatalf("DeleteFolder: %v", err)
}
folders, _ := store.GetFolders(anna.ID)
for _, f := range folders {
if f == "Projekte" || f == "Projekte/Leer" {
t.Fatalf("deleted folders must be gone from the tree: %v", folders)
}
}
trash, _ := store.GetTrash(anna.ID)
var folderEntry *TrashedDocument
for i := range trash {
if trash[i].Kind == "folder" {
folderEntry = &trash[i]
} else if trash[i].ID != loose.ID {
t.Errorf("notes of the folder belong to its entry, not to the list: %+v", trash[i])
}
}
if len(trash) != 2 || folderEntry == nil || folderEntry.Title != "Projekte" || folderEntry.NoteCount != 2 {
t.Fatalf("trash should list the folder with 2 notes and the single note: %+v", trash)
}
if _, err := store.RestoreFolder(ben.ID, folderEntry.ID); err != ErrNotInTrash {
t.Errorf("someone else must not restore the folder: %v", err)
}
restored, err := store.RestoreFolder(anna.ID, folderEntry.ID)
if err != nil || len(restored) != 2 {
t.Fatalf("RestoreFolder: %v %v", restored, err)
}
folders, _ = store.GetFolders(anna.ID)
want := map[string]bool{"Projekte": false, "Projekte/Stadt": false, "Projekte/Leer": false}
for _, f := range folders {
if _, ok := want[f]; ok {
want[f] = true
}
}
for f, ok := range want {
if !ok {
t.Errorf("folder %q should be back (empty ones too): %v", f, folders)
}
}
for _, id := range []string{top.ID, inner.ID} {
if d, err := store.GetDocumentByID(anna.ID, id); err != nil || d.IsArchived {
t.Errorf("note %s should be back: %+v %v", id, d, err)
}
}
if trash, _ = store.GetTrash(anna.ID); len(trash) != 1 || trash[0].ID != loose.ID {
t.Fatalf("only the single note stays in the trash: %+v", trash)
}
// Deleted for good: folder entry and notes are gone
_, _ = store.DeleteFolder(anna.ID, "Projekte")
trash, _ = store.GetTrash(anna.ID)
for _, e := range trash {
if e.Kind == "folder" {
if ids, err := store.PurgeFolder(anna.ID, e.ID); err != nil || len(ids) != 2 {
t.Fatalf("PurgeFolder: %v %v", ids, err)
}
}
}
var n int
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id IN (?, ?)`, top.ID, inner.ID).Scan(&n)
if n != 0 {
t.Errorf("notes of a purged folder must be gone")
}
// Expired like notes: entry and notes vanish with the notes trash delay
again, _ := store.CreateDocument(anna.ID, "", "Neu", "", "Archiv", nil, nil, false)
_, _ = store.CreateFolder(anna.ID, "Archiv")
_, _ = store.DeleteFolder(anna.ID, "Archiv")
if _, err := store.PurgeExpired(time.Now().AddDate(0, 2, 0), true); err != nil {
t.Fatal(err)
}
_ = store.db.QueryRow(`SELECT COUNT(*) FROM trashed_folders`).Scan(&n)
var docs int
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, again.ID).Scan(&docs)
if n != 0 || docs != 0 {
t.Errorf("expired folder and its note should be gone: folders %d, notes %d", n, docs)
}
}