package storage import ( "database/sql" "errors" "github.com/sandrodach/mindsift/internal/domain" ) // Deleted notes go to a trash first (documents.is_archived = 1). Trashed notes vanish from the tree, // search, export, stats and for everyone they were shared with; their owner can still read them, // restore them or delete them for good. archived_by records who trashed the note (the owner, or the // owner of the shared folder it lived in), so that person sees it in their trash as well. // ErrNotInTrash is returned when restoring or purging a note that is not in the user's trash. 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. type TrashedDocument struct { ID string `json:"id"` Title string `json:"title"` Icon string `json:"icon"` FolderPath string `json:"folderPath"` DeletedAt string `json:"deletedAt"` IsOwner bool `json:"isOwner"` } const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)` func trashStmts(userID, docID string) []sqlStmt { return []sqlStmt{ stmt(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`, domain.NowISO(), userID, docID), stmt(`DELETE FROM doc_pins WHERE doc_id = ?`, docID), } } 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. 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) if err != nil { return nil, err } defer rows.Close() out := []TrashedDocument{} for rows.Next() { var t TrashedDocument var sharedFolderID, subPath, ownerID, folderName string if err := rows.Scan(&t.ID, &t.Title, &t.Icon, &t.FolderPath, &sharedFolderID, &subPath, &t.DeletedAt, &ownerID, &folderName); err != nil { continue } if sharedFolderID != "" { t.FolderPath = folderName if subPath != "" { t.FolderPath += "/" + subPath } } t.IsOwner = ownerID == userID out = append(out, t) } return out, nil } 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) if err == sql.ErrNoRows { return "", ErrNotInTrash } return owner, err } // RestoreDocument takes a note out of the trash. Its folder is created again if it was deleted in the // meantime; a shared folder that no longer exists puts the note back into the owner's top level. func (s *Store) RestoreDocument(userID, docID string) error { owner, err := s.trashedDocOwner(userID, docID) if err != nil { return err } var folderPath, sharedFolderID string _ = s.db.QueryRow(`SELECT folder_path, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&folderPath, &sharedFolderID) if sharedFolderID != "" { var exists int _ = s.db.QueryRow(`SELECT COUNT(*) FROM workspace_shared_folders WHERE id = ?`, sharedFolderID).Scan(&exists) if exists == 0 { if _, err := s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID); err != nil { return err } } } else if folderPath != "" { if _, err := s.CreateFolder(owner, folderPath); err != nil { return err } } _, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID) return err } // PurgeDocument deletes a trashed note for good. func (s *Store) PurgeDocument(userID, docID string) error { if _, err := s.trashedDocOwner(userID, docID); err != nil { return err } return s.hardDeleteDocument(docID) } // EmptyTrash deletes every note in the user's trash for good and returns their IDs. func (s *Store) EmptyTrash(userID string) ([]string, error) { items, err := s.GetTrash(userID) if err != nil { return nil, err } ids := make([]string, 0, len(items)) for _, t := range items { if err := s.hardDeleteDocument(t.ID); err != nil { return ids, err } ids = append(ids, t.ID) } return ids, nil } func (s *Store) hardDeleteDocument(docID string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() for _, q := range []string{ // Tasks created from blocks of this note keep existing without the link `UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block' AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) = ?`, `DELETE FROM blocks WHERE document_id = ?`, `DELETE FROM block_tags WHERE document_id = ?`, `DELETE FROM doc_pins WHERE doc_id = ?`, `DELETE FROM doc_shares WHERE doc_id = ?`, `DELETE FROM doc_team_shares WHERE doc_id = ?`, `DELETE FROM doc_user_folders WHERE doc_id = ?`, `DELETE FROM documents WHERE id = ?`, } { if _, err := tx.Exec(q, docID); err != nil { return err } } return tx.Commit() }