feat: complete trash restore and batch actions
This commit is contained in:
@@ -1,23 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrashDTO struct {
|
||||
TrashPath string `json:"trashPath"`
|
||||
Count int `json:"count"`
|
||||
Nodes []TrashNodeDTO `json:"nodes"`
|
||||
Entries []TrashEntryDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type TrashNodeDTO struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
FsPath string `json:"fsPath"`
|
||||
NodePath string `json:"nodePath"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
|
||||
@@ -45,11 +51,17 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
if n.DeletedAt != nil {
|
||||
deletedAt = n.DeletedAt.Format(time.RFC3339)
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
nodes = append(nodes, TrashNodeDTO{
|
||||
ID: n.ID,
|
||||
ParentID: parentID,
|
||||
Title: n.Title,
|
||||
Type: n.Type,
|
||||
FsPath: n.FsPath,
|
||||
NodePath: a.nodes.Path(n.ID),
|
||||
DeletedAt: deletedAt,
|
||||
})
|
||||
}
|
||||
@@ -58,7 +70,207 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TrashDTO{TrashPath: trashPath, Nodes: nodes, Entries: entries}, nil
|
||||
return &TrashDTO{TrashPath: trashPath, Count: len(nodes), Nodes: nodes, Entries: entries}, nil
|
||||
}
|
||||
|
||||
func (a *App) TrashCount() (int, error) {
|
||||
trash, err := a.ListTrash()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return trash.Count, nil
|
||||
}
|
||||
|
||||
func (a *App) RestoreTrashNode(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
chain, err := a.deletedAncestorChain(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range chain {
|
||||
if err := a.restoreTrashPath(n.ID, n.FsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE nodes SET deleted_at = NULL, updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), n.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RestoreTrashNodesJSON(nodeIDsJSON string) error {
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.RestoreTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PurgeTrashNodesJSON(nodeIDsJSON string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.purgeTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) EmptyTrash() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
trash, err := a.ListTrash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, 0, len(trash.Nodes))
|
||||
for _, n := range trash.Nodes {
|
||||
if n.ParentID == "" {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
for _, n := range trash.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.purgeTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(a.vault, ".verstak", "trash"))
|
||||
}
|
||||
|
||||
func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
|
||||
var reversed []TrashNodeDTO
|
||||
current := nodeID
|
||||
for current != "" {
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.DeletedAt == nil {
|
||||
break
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
reversed = append(reversed, TrashNodeDTO{ID: n.ID, ParentID: parentID, Title: n.Title, Type: n.Type, FsPath: n.FsPath})
|
||||
current = parentID
|
||||
}
|
||||
if len(reversed) == 0 {
|
||||
return nil, fmt.Errorf("deleted node not found")
|
||||
}
|
||||
chain := make([]TrashNodeDTO, 0, len(reversed))
|
||||
for i := len(reversed) - 1; i >= 0; i-- {
|
||||
chain = append(chain, reversed[i])
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
||||
if fsPath == "" {
|
||||
return nil
|
||||
}
|
||||
trashEntry, err := a.findTrashEntryForNode(nodeID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dst := filepath.Join(a.vault, fsPath)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(trashEntry, dst)
|
||||
}
|
||||
|
||||
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
entries, err := os.ReadDir(trashPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := nodeID + "_"
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), prefix) {
|
||||
return filepath.Join(trashPath, entry.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("trash entry not found")
|
||||
}
|
||||
|
||||
func (a *App) purgeTrashNode(nodeID string) error {
|
||||
ids, err := a.deletedSubtreeIDs(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if path, err := a.findTrashEntryForNode(id); err == nil {
|
||||
_ = os.RemoveAll(path)
|
||||
}
|
||||
}
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
id := ids[i]
|
||||
_, _ = tx.Exec(`DELETE FROM node_meta WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM notes WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM actions WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM links WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM worklog_entry_events WHERE entry_id IN (SELECT id FROM worklog_entries WHERE node_id = ?)`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM worklog_entries WHERE node_id = ?`, id)
|
||||
if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) deletedSubtreeIDs(nodeID string) ([]string, error) {
|
||||
rows, err := a.db.Query(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
WHERE n.deleted_at IS NOT NULL
|
||||
) SELECT id FROM subtree`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("deleted node not found")
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,8 +19,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-DOH0BsUz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DRlK-DBn.css">
|
||||
<script type="module" crossorigin src="/assets/main-mEr4zvhI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-SjC7TazH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -43,3 +45,72 @@ func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
|
||||
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "Specs", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
other, err := app.CreateNodeFromTemplate(parent.ID, "Drafts", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
if err := app.RestoreTrashNode(child.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode(child): %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{parent.ID, child.ID} {
|
||||
if _, err := app.nodes.GetActive(id); err != nil {
|
||||
t.Fatalf("node %s should be active after restore: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := app.nodes.GetActive(other.ID); err == nil {
|
||||
t.Fatalf("unselected sibling should remain deleted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vault, "Documents", "Specs")); err != nil {
|
||||
t.Fatalf("restored child path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashCountPurgeAndEmpty(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default")
|
||||
b, _ := app.CreateNodeFromTemplate("", "Trash B", "folder.default")
|
||||
if err := app.DeleteNode(a.ID); err != nil {
|
||||
t.Fatalf("delete A: %v", err)
|
||||
}
|
||||
if err := app.DeleteNode(b.ID); err != nil {
|
||||
t.Fatalf("delete B: %v", err)
|
||||
}
|
||||
count, err := app.TrashCount()
|
||||
if err != nil {
|
||||
t.Fatalf("TrashCount: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("TrashCount = %d, want 2", count)
|
||||
}
|
||||
if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil {
|
||||
t.Fatalf("PurgeTrashNodesJSON: %v", err)
|
||||
}
|
||||
count, _ = app.TrashCount()
|
||||
if count != 1 {
|
||||
t.Fatalf("TrashCount after purge = %d, want 1", count)
|
||||
}
|
||||
if err := app.EmptyTrash(); err != nil {
|
||||
t.Fatalf("EmptyTrash: %v", err)
|
||||
}
|
||||
count, _ = app.TrashCount()
|
||||
if count != 0 {
|
||||
t.Fatalf("TrashCount after empty = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user