511 lines
14 KiB
Go
511 lines
14 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
|
|
} else if recs, recErr := a.files.ListTrashedByNode(n.ID); recErr == nil && len(recs) > 0 {
|
|
// Try file records (for TypeFile nodes whose files were individually moved).
|
|
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
|
for _, r := range recs {
|
|
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
|
if _, stErr := os.Stat(candidate); stErr == nil {
|
|
dto.TrashFsPath = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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 == "" {
|
|
// TypeFile node — restore file records that were marked missing=1.
|
|
recs, err := a.files.ListTrashedByNode(nodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
|
for _, r := range recs {
|
|
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
|
if _, err := os.Stat(trashPath); os.IsNotExist(err) {
|
|
continue // already restored via parent dir
|
|
}
|
|
dst := filepath.Join(a.vault, r.Path)
|
|
rel, rErr := filepath.Rel(a.vault, dst)
|
|
if rErr != nil || strings.HasPrefix(rel, "..") {
|
|
return fmt.Errorf("path safety: %s", r.Path)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(trashPath, dst); err != nil {
|
|
return fmt.Errorf("restore file %s: %w", r.ID, err)
|
|
}
|
|
if _, err := a.db.Exec("UPDATE files SET missing=0, updated_at=? WHERE id=?", nowStr(), r.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Directory-type node — move the whole directory back from trash.
|
|
trashEntry, err := a.findTrashEntryForNode(nodeID)
|
|
if err != nil {
|
|
return nil // parent may have already been restored
|
|
}
|
|
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 nowStr() string {
|
|
return time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
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 {
|
|
// Try direct trash entry (folder-type nodes: nodeID_title).
|
|
if path, err := a.findTrashEntryForNode(id); err == nil {
|
|
_ = os.RemoveAll(path)
|
|
}
|
|
// Try file record trash entries (file/note nodes: fileID_filename).
|
|
// These are created by files.trashRecord and not found by findTrashEntryForNode.
|
|
if recs, err := a.files.ListTrashedByNode(id); err == nil {
|
|
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
|
for _, r := range recs {
|
|
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
|
_ = os.RemoveAll(trashPath)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// ReadTrashFile reads a trash file by its absolute filesystem path.
|
|
// This is preferred over ReadTrashFileContent (which re-resolves by nodeID)
|
|
// because the frontend already has the precomputed trashFsPath from ListTrash.
|
|
func (a *App) ReadTrashFile(trashFsPath string) (string, error) {
|
|
if err := a.requireVault(); err != nil {
|
|
return "", err
|
|
}
|
|
data, err := os.ReadFile(trashFsPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), 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 (directory-type nodes), it looks up <nodeID>_* in
|
|
// the trash dir. For file-type nodes it searches by file record. For nested
|
|
// files (moved inside a parent folder) it walks up the ancestor chain.
|
|
func (a *App) resolveTrashPath(nodeID string) (string, error) {
|
|
// 1. Try direct lookup first (for directory-type nodes).
|
|
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
|
|
return p, nil
|
|
}
|
|
|
|
// 2. Try file records (for TypeFile nodes whose files were individually moved).
|
|
recs, err := a.files.ListTrashedByNode(nodeID)
|
|
if err == nil {
|
|
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
|
for _, r := range recs {
|
|
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
|
if info, stErr := os.Stat(candidate); stErr == nil && !info.IsDir() {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Walk parent chain to find nearest ancestor with a direct trash entry
|
|
// (for files nested inside a deleted parent directory).
|
|
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 = ""
|
|
}
|
|
}
|
|
for i := 0; i < len(chain); i++ {
|
|
anc := chain[i]
|
|
ancPath, err := a.findTrashEntryForNode(anc.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Path 3a: compute relative path from ancestor's FsPath to target's FsPath.
|
|
if chain[0].FsPath != "" && anc.FsPath != "" && strings.HasPrefix(chain[0].FsPath, anc.FsPath) {
|
|
rel := strings.TrimPrefix(chain[0].FsPath, anc.FsPath)
|
|
rel = strings.TrimPrefix(rel, "/")
|
|
if rel != "" {
|
|
fullPath := filepath.Join(ancPath, rel)
|
|
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
|
return fullPath, nil
|
|
}
|
|
}
|
|
}
|
|
// Path 3b: try node title as a direct child 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()
|
|
}
|