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 { 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 } // 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 _* 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() }