feat: complete trash restore and batch actions

This commit is contained in:
mirivlad 2026-06-05 12:43:30 +08:00
parent 10b287de7b
commit 1fa009b1e2
10 changed files with 397 additions and 14 deletions

View File

@ -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

View File

@ -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>

View File

@ -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)
}
}

View File

@ -102,6 +102,9 @@
let linkNote = ''
let linkStatus = ''
let trashInfo = null
let trashCount = 0
let trashSelectedIds = []
let trashFolderId = ''
let showCreateNode = false
let newNodeTitle = ''
let createInNode = null
@ -237,6 +240,7 @@
loading = false
loadSyncStatus()
refreshTrashCount()
})
onDestroy(() => {
@ -278,7 +282,7 @@
} else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'trash') {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '' }
await refreshTrash()
} else if (id === 'journal') {
await loadJournal()
} else if (id === 'activity') {
@ -882,6 +886,7 @@
try {
await wailsCall('DeleteNode', node.id)
await reloadTreePreservingExpanded()
await refreshTrashCount()
if (selectedNode && selectedNode.id === node.id) {
selectedNode = null
}
@ -1168,6 +1173,73 @@
}
}
async function refreshTrash() {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '', count: 0 }
trashCount = trashInfo.count || 0
trashSelectedIds = trashSelectedIds.filter(id => (trashInfo.nodes || []).some(node => node.id === id))
if (trashFolderId && !(trashInfo.nodes || []).some(node => node.id === trashFolderId)) {
trashFolderId = ''
}
}
async function refreshTrashCount() {
try { trashCount = await wailsCall('TrashCount') || 0 } catch (e) { trashCount = 0 }
}
function trashVisibleNodes() {
const nodes = trashInfo?.nodes || []
if (!trashFolderId) return nodes.filter(node => !node.parentId || !nodes.some(other => other.id === node.parentId))
return nodes.filter(node => node.parentId === trashFolderId)
}
function toggleTrashSelection(id) {
trashSelectedIds = trashSelectedIds.includes(id)
? trashSelectedIds.filter(existing => existing !== id)
: [...trashSelectedIds, id]
}
function trashSelectionOr(id) {
return trashSelectedIds.length > 0 ? trashSelectedIds : [id]
}
async function restoreTrash(ids) {
try {
await wailsCall('RestoreTrashNodesJSON', JSON.stringify(ids))
await reloadTreePreservingExpanded()
await refreshTrash()
} catch (e) { error = String(e) }
}
async function purgeTrash(ids) {
openConfirm({
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + ids.length + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('PurgeTrashNodesJSON', JSON.stringify(ids))
await refreshTrash()
} catch (e) { error = String(e) }
}
})
}
async function emptyTrash() {
openConfirm({
title: t('delete.confirmTitle'),
message: t('trash.empty') + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('EmptyTrash')
await refreshTrash()
} catch (e) { error = String(e) }
}
})
}
function openSuggestionWorklogModal(s) {
acceptingSuggestion = s
editingWorklogEntry = null
@ -2116,6 +2188,9 @@
{#if view.id === 'journal' && suggestionCount > 0}
<span class="nav-badge">{suggestionCount}</span>
{/if}
{#if view.id === 'trash' && trashCount > 0}
<span class="nav-badge">{trashCount}</span>
{/if}
</button>
{/each}
</div>
@ -2654,7 +2729,14 @@
<h2>{t('nav.trash')}</h2>
<p>{trashInfo?.trashPath || ''}</p>
</div>
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
<div class="trash-actions">
{#if trashSelectedIds.length > 0}
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectedIds)}>{t('trash.restore')} ({trashSelectedIds.length})</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectedIds)}>{t('common.delete')} ({trashSelectedIds.length})</button>
{/if}
<button class="btn btn-sm btn-danger" on:click={emptyTrash}>{t('trash.emptyTrash')}</button>
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
</div>
</div>
{#if !trashInfo || ((trashInfo.nodes || []).length === 0 && (trashInfo.entries || []).length === 0)}
<div class="empty-state">
@ -2667,13 +2749,24 @@
{#if (trashInfo.nodes || []).length === 0}
<p class="trash-empty-line">{t('common.empty')}</p>
{:else}
{#each trashInfo.nodes as node}
<div class="trash-row">
{#if trashFolderId}
<button class="btn btn-sm back-btn" on:click={() => { trashFolderId = ''; trashSelectedIds = [] }}>{t('common.backLabel')}</button>
{/if}
{#each trashVisibleNodes() as node}
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)}>
<input type="checkbox" checked={trashSelectedIds.includes(node.id)} on:change={() => toggleTrashSelection(node.id)} />
<div class="trash-row-main">
<span class="trash-row-title">{node.title}</span>
<span class="trash-row-meta">{nodeKindLabel(node.type)} · {formatDate(node.deletedAt)}</span>
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)} · {formatDate(node.deletedAt)}</span>
</div>
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
<div class="trash-row-actions">
{#if node.type !== 'file' && node.type !== 'note'}
<button class="btn btn-sm" on:click={() => { trashFolderId = node.id; trashSelectedIds = [] }}>{t('common.open')}</button>
{/if}
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectionOr(node.id))}>{t('trash.restore')}</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectionOr(node.id))}>{t('common.delete')}</button>
</div>
</div>
{/each}
{/if}
@ -3527,14 +3620,17 @@
.trash-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.trash-header h2 { margin: 0 0 6px; }
.trash-header p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
.trash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.trash-section { min-width: 0; }
.trash-section h3 { margin: 0 0 10px; font-size: 13px; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; }
.trash-row { padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row.selected { border-color: #6366f1; background: #20203a; }
.trash-row-main { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-meta { flex-shrink: 0; color: #8888a0; font-size: 12px; }
.trash-row-path { display: block; margin-top: 4px; color: #707088; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-path { grid-column: 2 / -1; display: block; color: #707088; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
/* Journal screen */

View File

@ -55,6 +55,8 @@ export default {
'capture.dropOverlayGlobal': 'Will be added to global Inbox',
'trash.openFolder': 'Open trash folder',
'trash.empty': 'Trash is empty',
'trash.emptyTrash': 'Empty trash',
'trash.restore': 'Restore',
'trash.deletedNodes': 'Deleted items',
'trash.physicalEntries': 'Files in .verstak/trash',
'tab.overview': 'Overview',

View File

@ -58,6 +58,8 @@ export default {
'trash.openFolder': 'Открыть папку корзины',
'trash.empty': 'Корзина пуста',
'trash.emptyTrash': 'Очистить корзину',
'trash.restore': 'Восстановить',
'trash.deletedNodes': 'Удаленные элементы',
'trash.physicalEntries': 'Файлы в .verstak/trash',