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

+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
}