feat(workspace): trash for notes
Deleting a note (or its folder) moves it to the trash instead of erasing it. Trashed notes leave the tree, search, export and stats and are hidden from everyone they were shared with; the owner sees them read-only with a restore banner. New endpoints list, restore, purge and empty the trash; restoring recreates a deleted folder. Sidebar entry and mobile menu item with counter, list view with two-tap permanent delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
344f5e4ab2
commit
f69f74fdc7
15 files changed
+649
-40
No files matched your search
+24
-17
@@ -379,6 +379,8 @@ func (s *Store) migrate() error {
|
||||
|
||||
// Idempotent column migrations for existing databases
|
||||
columnMigrations := []string{
|
||||
"ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
|
||||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||||
@@ -1979,13 +1981,16 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
||||
d.shared_folder_id, d.sub_folder_path
|
||||
FROM documents d
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.id = ? AND d.user_id = ?`,
|
||||
userID, docID, userID,
|
||||
WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`,
|
||||
userID, docID, userID, userID,
|
||||
)
|
||||
doc, err := s.scanDocumentWithBlocks(row)
|
||||
if err == nil {
|
||||
doc.Permission = "owner"
|
||||
doc.IsOwner = true
|
||||
doc.IsOwner = doc.UserID == userID
|
||||
if doc.IsArchived {
|
||||
doc.Permission = "read" // read-only while in the trash
|
||||
}
|
||||
resolveDocumentPath(userID, doc, mounts, names, userFolders)
|
||||
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
||||
doc.SharedWith = shares
|
||||
@@ -2022,7 +2027,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
||||
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
|
||||
WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
|
||||
LIMIT 1`,
|
||||
userID, userID, userID, userID, userID, userID, docID, userID, userID,
|
||||
)
|
||||
@@ -2060,6 +2065,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return nil, ErrDocInTrash
|
||||
}
|
||||
|
||||
// Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is
|
||||
// only changed through the operation log (POST /docs/{id}/sync).
|
||||
@@ -2121,11 +2129,10 @@ func (s *Store) DeleteDocument(userID, docID string) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||
return s.DeleteDocShare(userID, docID, userID)
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID)
|
||||
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID)
|
||||
return err
|
||||
if doc.IsArchived {
|
||||
return s.hardDeleteDocument(docID)
|
||||
}
|
||||
return s.trashDocument(userID, docID)
|
||||
}
|
||||
|
||||
func (s *Store) reindexAllDocBlockTags() error {
|
||||
@@ -2596,15 +2603,9 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
// Delete documents from SQLite, blocks, pins, shares, tags, user_folders
|
||||
// The notes of the folder go to the trash; restoring one creates its folder again
|
||||
for _, id := range deletedDocIDs {
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_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 doc_team_shares WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM documents WHERE id = ?`, id)
|
||||
_ = s.trashDocument(userID, id)
|
||||
}
|
||||
|
||||
// Also clear any personal folder placements for shared notes in this folder
|
||||
@@ -2619,6 +2620,9 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return nil, ErrDocInTrash
|
||||
}
|
||||
|
||||
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
|
||||
if cleanTarget == "." {
|
||||
@@ -3376,6 +3380,9 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return nil, ErrDocInTrash
|
||||
}
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user