feat: complete trash restore and batch actions
This commit is contained in:
parent
10b287de7b
commit
1fa009b1e2
|
|
@ -1,23 +1,29 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TrashDTO struct {
|
type TrashDTO struct {
|
||||||
TrashPath string `json:"trashPath"`
|
TrashPath string `json:"trashPath"`
|
||||||
|
Count int `json:"count"`
|
||||||
Nodes []TrashNodeDTO `json:"nodes"`
|
Nodes []TrashNodeDTO `json:"nodes"`
|
||||||
Entries []TrashEntryDTO `json:"entries"`
|
Entries []TrashEntryDTO `json:"entries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrashNodeDTO struct {
|
type TrashNodeDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
ParentID string `json:"parentId,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
FsPath string `json:"fsPath"`
|
FsPath string `json:"fsPath"`
|
||||||
|
NodePath string `json:"nodePath"`
|
||||||
DeletedAt string `json:"deletedAt"`
|
DeletedAt string `json:"deletedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,11 +51,17 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||||
if n.DeletedAt != nil {
|
if n.DeletedAt != nil {
|
||||||
deletedAt = n.DeletedAt.Format(time.RFC3339)
|
deletedAt = n.DeletedAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
parentID := ""
|
||||||
|
if n.ParentID != nil {
|
||||||
|
parentID = *n.ParentID
|
||||||
|
}
|
||||||
nodes = append(nodes, TrashNodeDTO{
|
nodes = append(nodes, TrashNodeDTO{
|
||||||
ID: n.ID,
|
ID: n.ID,
|
||||||
|
ParentID: parentID,
|
||||||
Title: n.Title,
|
Title: n.Title,
|
||||||
Type: n.Type,
|
Type: n.Type,
|
||||||
FsPath: n.FsPath,
|
FsPath: n.FsPath,
|
||||||
|
NodePath: a.nodes.Path(n.ID),
|
||||||
DeletedAt: deletedAt,
|
DeletedAt: deletedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +70,207 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DOH0BsUz.js"></script>
|
<script type="module" crossorigin src="/assets/main-mEr4zvhI.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DRlK-DBn.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-SjC7TazH.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
@ -43,3 +45,72 @@ func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
|
||||||
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,9 @@
|
||||||
let linkNote = ''
|
let linkNote = ''
|
||||||
let linkStatus = ''
|
let linkStatus = ''
|
||||||
let trashInfo = null
|
let trashInfo = null
|
||||||
|
let trashCount = 0
|
||||||
|
let trashSelectedIds = []
|
||||||
|
let trashFolderId = ''
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
let newNodeTitle = ''
|
let newNodeTitle = ''
|
||||||
let createInNode = null
|
let createInNode = null
|
||||||
|
|
@ -237,6 +240,7 @@
|
||||||
|
|
||||||
loading = false
|
loading = false
|
||||||
loadSyncStatus()
|
loadSyncStatus()
|
||||||
|
refreshTrashCount()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -278,7 +282,7 @@
|
||||||
} else if (id === 'inbox') {
|
} else if (id === 'inbox') {
|
||||||
inboxNodes = await wailsCall('ListInboxNodes') || []
|
inboxNodes = await wailsCall('ListInboxNodes') || []
|
||||||
} else if (id === 'trash') {
|
} else if (id === 'trash') {
|
||||||
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '' }
|
await refreshTrash()
|
||||||
} else if (id === 'journal') {
|
} else if (id === 'journal') {
|
||||||
await loadJournal()
|
await loadJournal()
|
||||||
} else if (id === 'activity') {
|
} else if (id === 'activity') {
|
||||||
|
|
@ -882,6 +886,7 @@
|
||||||
try {
|
try {
|
||||||
await wailsCall('DeleteNode', node.id)
|
await wailsCall('DeleteNode', node.id)
|
||||||
await reloadTreePreservingExpanded()
|
await reloadTreePreservingExpanded()
|
||||||
|
await refreshTrashCount()
|
||||||
if (selectedNode && selectedNode.id === node.id) {
|
if (selectedNode && selectedNode.id === node.id) {
|
||||||
selectedNode = null
|
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) {
|
function openSuggestionWorklogModal(s) {
|
||||||
acceptingSuggestion = s
|
acceptingSuggestion = s
|
||||||
editingWorklogEntry = null
|
editingWorklogEntry = null
|
||||||
|
|
@ -2116,6 +2188,9 @@
|
||||||
{#if view.id === 'journal' && suggestionCount > 0}
|
{#if view.id === 'journal' && suggestionCount > 0}
|
||||||
<span class="nav-badge">{suggestionCount}</span>
|
<span class="nav-badge">{suggestionCount}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if view.id === 'trash' && trashCount > 0}
|
||||||
|
<span class="nav-badge">{trashCount}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2654,7 +2729,14 @@
|
||||||
<h2>{t('nav.trash')}</h2>
|
<h2>{t('nav.trash')}</h2>
|
||||||
<p>{trashInfo?.trashPath || ''}</p>
|
<p>{trashInfo?.trashPath || ''}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{#if !trashInfo || ((trashInfo.nodes || []).length === 0 && (trashInfo.entries || []).length === 0)}
|
{#if !trashInfo || ((trashInfo.nodes || []).length === 0 && (trashInfo.entries || []).length === 0)}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|
@ -2667,13 +2749,24 @@
|
||||||
{#if (trashInfo.nodes || []).length === 0}
|
{#if (trashInfo.nodes || []).length === 0}
|
||||||
<p class="trash-empty-line">{t('common.empty')}</p>
|
<p class="trash-empty-line">{t('common.empty')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each trashInfo.nodes as node}
|
{#if trashFolderId}
|
||||||
<div class="trash-row">
|
<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">
|
<div class="trash-row-main">
|
||||||
<span class="trash-row-title">{node.title}</span>
|
<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>
|
</div>
|
||||||
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
|
{#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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -3527,14 +3620,17 @@
|
||||||
.trash-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
.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 h2 { margin: 0 0 6px; }
|
||||||
.trash-header p { margin: 0; color: #8888a0; font-size: 12px; }
|
.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-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||||
.trash-section { min-width: 0; }
|
.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-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-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-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-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; }
|
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
|
||||||
|
|
||||||
/* Journal screen */
|
/* Journal screen */
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ export default {
|
||||||
'capture.dropOverlayGlobal': 'Will be added to global Inbox',
|
'capture.dropOverlayGlobal': 'Will be added to global Inbox',
|
||||||
'trash.openFolder': 'Open trash folder',
|
'trash.openFolder': 'Open trash folder',
|
||||||
'trash.empty': 'Trash is empty',
|
'trash.empty': 'Trash is empty',
|
||||||
|
'trash.emptyTrash': 'Empty trash',
|
||||||
|
'trash.restore': 'Restore',
|
||||||
'trash.deletedNodes': 'Deleted items',
|
'trash.deletedNodes': 'Deleted items',
|
||||||
'trash.physicalEntries': 'Files in .verstak/trash',
|
'trash.physicalEntries': 'Files in .verstak/trash',
|
||||||
'tab.overview': 'Overview',
|
'tab.overview': 'Overview',
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ export default {
|
||||||
|
|
||||||
'trash.openFolder': 'Открыть папку корзины',
|
'trash.openFolder': 'Открыть папку корзины',
|
||||||
'trash.empty': 'Корзина пуста',
|
'trash.empty': 'Корзина пуста',
|
||||||
|
'trash.emptyTrash': 'Очистить корзину',
|
||||||
|
'trash.restore': 'Восстановить',
|
||||||
'trash.deletedNodes': 'Удаленные элементы',
|
'trash.deletedNodes': 'Удаленные элементы',
|
||||||
'trash.physicalEntries': 'Файлы в .verstak/trash',
|
'trash.physicalEntries': 'Файлы в .verstak/trash',
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue