verstak/cmd/verstak-gui/bindings_trash.go

461 lines
12 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"verstak/internal/core/nodes"
)
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"`
TrashFsPath string `json:"trashFsPath"`
DeletedAt string `json:"deletedAt"`
}
type TrashEntryDTO struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
func (a *App) ListTrash() (*TrashDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
deleted, err := a.nodes.ListDeleted()
if err != nil {
return nil, err
}
// Phase 1: build all DTOs and compute direct trash paths for folder-type nodes.
nodeMap := make(map[string]*TrashNodeDTO, len(deleted))
var allDeleted []nodes.Node
for _, n := range deleted {
allDeleted = append(allDeleted, n)
}
for _, n := range allDeleted {
deletedAt := ""
if n.DeletedAt != nil {
deletedAt = n.DeletedAt.Format(time.RFC3339)
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
dto := &TrashNodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
FsPath: n.FsPath,
NodePath: a.nodes.Path(n.ID),
DeletedAt: deletedAt,
}
nodeMap[n.ID] = dto
// Try direct trash entry (for folders that were os.Rename'd).
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
dto.TrashFsPath = p
}
}
// Phase 2: propagate trash paths from parents to children that have no direct entry
// but whose FsPath starts with the parent's FsPath.
changed := true
for changed {
changed = false
for _, n := range allDeleted {
dto := nodeMap[n.ID]
if dto.TrashFsPath != "" {
continue
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
if parentID == "" {
continue
}
parent := nodeMap[parentID]
if parent == nil || parent.TrashFsPath == "" {
continue
}
// Child inherits parent's trash path + relative FsPath fragment.
if n.FsPath != "" && parent.FsPath != "" && strings.HasPrefix(n.FsPath, parent.FsPath) {
rel := strings.TrimPrefix(n.FsPath, parent.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
dto.TrashFsPath = filepath.Join(parent.TrashFsPath, rel)
changed = true
}
}
}
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
for _, dto := range nodeMap {
nodes = append(nodes, *dto)
}
entries, err := listTrashEntries(trashPath)
if err != nil {
return nil, err
}
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) {
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return nil, err
}
dirEntries, err := os.ReadDir(trashPath)
if err != nil {
return nil, err
}
out := make([]TrashEntryDTO, 0, len(dirEntries))
for _, entry := range dirEntries {
info, err := entry.Info()
if err != nil {
continue
}
out = append(out, TrashEntryDTO{
Name: entry.Name(),
Path: filepath.Join(trashPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
return out, nil
}
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
path, err := a.resolveTrashPath(nodeID)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// resolveTrashPath finds the physical path of a deleted node's file in the trash.
// For directly-moved entries (folders), it looks up <nodeID>_* in trash dir.
// For nested files (moved as part of a parent folder), it walks up the ancestor
// chain until it finds a folder with a direct trash entry, then appends the
// relative filesystem path.
func (a *App) resolveTrashPath(nodeID string) (string, error) {
// 1. Try direct lookup first (for nodes that were individually moved).
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
return p, nil
}
// 2. Walk parent chain to find nearest ancestor with a direct trash entry.
type step struct {
ID string
FsPath string
Title string
}
var chain []step
current := nodeID
for current != "" {
n, err := a.nodes.Get(current)
if err != nil {
break
}
chain = append(chain, step{ID: n.ID, FsPath: n.FsPath, Title: n.Title})
if n.ParentID != nil {
current = *n.ParentID
} else {
current = ""
}
}
// chain[0] = the node itself, chain[len-1] = topmost ancestor.
// Walk from closest ancestor outward to find a trash entry.
for i := 0; i < len(chain); i++ {
anc := chain[i]
ancPath, err := a.findTrashEntryForNode(anc.ID)
if err != nil {
continue
}
// Build the relative path from this ancestor down to the original node.
// The node at index 0 is the target; any step between anc and index 0
// contributes a subdirectory name.
// We walk from anc up to target (index 0), collecting FsPath segments.
var relSegments []string
for j := 0; j < i; j++ {
child := chain[j]
if child.FsPath != "" && anc.FsPath != "" && strings.HasPrefix(child.FsPath, anc.FsPath) {
rel := strings.TrimPrefix(child.FsPath, anc.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
relSegments = append(relSegments, rel)
} else if child.Title != "" {
relSegments = append(relSegments, child.Title)
}
} else if child.Title != "" {
relSegments = append(relSegments, child.Title)
}
anc = child
}
fullPath := ancPath
if len(relSegments) > 0 {
fullPath = filepath.Join(ancPath, filepath.Join(relSegments...))
}
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
return fullPath, nil
}
}
// 3. Fallback: try node title as filename inside each ancestor's trash dir.
for _, anc := range chain {
ancPath, err := a.findTrashEntryForNode(anc.ID)
if err != nil {
continue
}
fi, err := os.Stat(ancPath)
if err != nil || !fi.IsDir() {
continue
}
// Try the node's title as a direct child file inside the ancestor dir.
candidate := filepath.Join(ancPath, chain[0].Title)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("trash file not found for node %s", nodeID)
}
func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil {
return err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return err
}
return exec.Command("xdg-open", trashPath).Run()
}