fix: trash integrity for TypeFile nodes — file record soft-delete, correct preview/restore
This commit is contained in:
parent
64e6c6f735
commit
a37afd3b67
16
AGENTS.md
16
AGENTS.md
|
|
@ -37,6 +37,22 @@
|
||||||
34. **Template enable/disable** — `AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
|
34. **Template enable/disable** — `AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
|
||||||
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
|
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
|
||||||
|
|
||||||
|
## Bugs fixed (this session)
|
||||||
|
|
||||||
|
1. **Trash preview "trash file not found" for TypeFile nodes** — `resolveTrashPath` only searched `<nodeID>_*` in trash dir, but TypeFile node files are moved by file record ID (`<recordID>_<filename>`). Added file record fallback via `ListTrashedByNode` + `ReadTrashFile(trashFsPath)` binding.
|
||||||
|
2. **Trash restore creates empty files** — `DeleteToTrash` permanently `DELETE`'d file records from DB and `restoreTrashPath` silently `return nil` for `FsPath=""` nodes. Changed `deleteFileRecords` to `trashRecord` (sets `missing=1`, keeps record). `restoreTrashPath` now restores file records: moves file back from trash, sets `missing=0`.
|
||||||
|
3. **`resolveTrashPath` inner loop corrupts `anc` variable** — `anc = child` inside the inner loop caused wrong path computation for nesting depth > 2. Replaced with direct `chain[0].FsPath` prefix computation.
|
||||||
|
4. **`ListTrash` missing `TrashFsPath` for TypeFile nodes** — Phase 1 only checked `<nodeID>_*` entries. Added `ListTrashedByNode` fallback to set `TrashFsPath` for TypeFile nodes.
|
||||||
|
5. **`ListByNode` returning trashed records** — Added `AND missing != 1` filter to exclude trashed file records from active node file listings.
|
||||||
|
|
||||||
|
## Key patterns (this session)
|
||||||
|
|
||||||
|
- **TypeFile node trash**: files are moved by file record ID (`<recordID>_<filename>`), NOT by node ID. Never search by `<nodeID>_*` alone — always fall back to file records via `ListTrashedByNode`.
|
||||||
|
- **File record soft-trash**: use `UPDATE files SET missing=1` instead of `DELETE` to keep records restorable. Restore via `UPDATE ... SET missing=0` + `os.Rename` from trash.
|
||||||
|
- `ReadTrashFile(trashFsPath)` is preferred over `ReadTrashFileContent(nodeID)` — frontend has `trashFsPath` precomputed by `ListTrash`.
|
||||||
|
- **`restoreTrashPath` for TypeFile nodes**: when `fsPath == ""`, find file records with `missing=1` and restore each one.
|
||||||
|
- **Full test coverage**: `TestTrashTypeFilePreviewAndRestore`, `TestTrashTypeFileInsideFolderRestorePreservesContent`, `TestTrashTypeFileMultipleRecords`.
|
||||||
|
|
||||||
## Key patterns
|
## Key patterns
|
||||||
- Always use explicit toggle icons (▸/▾) on expandable rows.
|
- Always use explicit toggle icons (▸/▾) on expandable rows.
|
||||||
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
|
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,16 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||||
// Try direct trash entry (for folders that were os.Rename'd).
|
// Try direct trash entry (for folders that were os.Rename'd).
|
||||||
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
|
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
|
||||||
dto.TrashFsPath = p
|
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
|
// Phase 2: propagate trash paths from parents to children that have no direct entry
|
||||||
|
|
@ -236,11 +246,39 @@ func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
|
||||||
|
|
||||||
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
||||||
if fsPath == "" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory-type node — move the whole directory back from trash.
|
||||||
trashEntry, err := a.findTrashEntryForNode(nodeID)
|
trashEntry, err := a.findTrashEntryForNode(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil // parent may have already been restored
|
||||||
}
|
}
|
||||||
dst := filepath.Join(a.vault, fsPath)
|
dst := filepath.Join(a.vault, fsPath)
|
||||||
if _, err := os.Stat(dst); err == nil {
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
|
@ -252,6 +290,10 @@ func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
||||||
return os.Rename(trashEntry, dst)
|
return os.Rename(trashEntry, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nowStr() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
|
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
|
||||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||||
entries, err := os.ReadDir(trashPath)
|
entries, err := os.ReadDir(trashPath)
|
||||||
|
|
@ -348,6 +390,20 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
||||||
return out, nil
|
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) {
|
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
|
||||||
if err := a.requireVault(); err != nil {
|
if err := a.requireVault(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -364,16 +420,29 @@ func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveTrashPath finds the physical path of a deleted node's file in the trash.
|
// 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 directly-moved entries (directory-type nodes), it looks up <nodeID>_* in
|
||||||
// For nested files (moved as part of a parent folder), it walks up the ancestor
|
// the trash dir. For file-type nodes it searches by file record. For nested
|
||||||
// chain until it finds a folder with a direct trash entry, then appends the
|
// files (moved inside a parent folder) it walks up the ancestor chain.
|
||||||
// relative filesystem path.
|
|
||||||
func (a *App) resolveTrashPath(nodeID string) (string, error) {
|
func (a *App) resolveTrashPath(nodeID string) (string, error) {
|
||||||
// 1. Try direct lookup first (for nodes that were individually moved).
|
// 1. Try direct lookup first (for directory-type nodes).
|
||||||
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
|
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
// 2. Walk parent chain to find nearest ancestor with a direct trash entry.
|
|
||||||
|
// 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 {
|
type step struct {
|
||||||
ID string
|
ID string
|
||||||
FsPath string
|
FsPath string
|
||||||
|
|
@ -393,53 +462,24 @@ func (a *App) resolveTrashPath(nodeID string) (string, error) {
|
||||||
current = ""
|
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++ {
|
for i := 0; i < len(chain); i++ {
|
||||||
anc := chain[i]
|
anc := chain[i]
|
||||||
ancPath, err := a.findTrashEntryForNode(anc.ID)
|
ancPath, err := a.findTrashEntryForNode(anc.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Build the relative path from this ancestor down to the original node.
|
// Path 3a: compute relative path from ancestor's FsPath to target's FsPath.
|
||||||
// The node at index 0 is the target; any step between anc and index 0
|
if chain[0].FsPath != "" && anc.FsPath != "" && strings.HasPrefix(chain[0].FsPath, anc.FsPath) {
|
||||||
// contributes a subdirectory name.
|
rel := strings.TrimPrefix(chain[0].FsPath, anc.FsPath)
|
||||||
// We walk from anc up to target (index 0), collecting FsPath segments.
|
rel = strings.TrimPrefix(rel, "/")
|
||||||
var relSegments []string
|
if rel != "" {
|
||||||
for j := 0; j < i; j++ {
|
fullPath := filepath.Join(ancPath, rel)
|
||||||
child := chain[j]
|
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||||
if child.FsPath != "" && anc.FsPath != "" && strings.HasPrefix(child.FsPath, anc.FsPath) {
|
return fullPath, nil
|
||||||
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
|
// Path 3b: try node title as a direct child inside the ancestor dir.
|
||||||
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)
|
candidate := filepath.Join(ancPath, chain[0].Title)
|
||||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
return candidate, nil
|
return candidate, nil
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -19,7 +19,7 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DGUrUZ5X.js"></script>
|
<script type="module" crossorigin src="/assets/main-DLPp-1Ge.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,304 @@ func TestTrashCountPurgeAndEmpty(t *testing.T) {
|
||||||
t.Fatalf("TrashCount after empty = %d, want 0", count)
|
t.Fatalf("TrashCount after empty = %d, want 0", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrashTypeFilePreviewAndRestore(t *testing.T) {
|
||||||
|
app, vault := setupTestApp(t)
|
||||||
|
|
||||||
|
// Create a folder to hold the file.
|
||||||
|
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a TypeFile node with a file record.
|
||||||
|
fileNode, err := app.CreateEmptyFile(parent.ID, "hello.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEmptyFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write some content to the physical file via file records.
|
||||||
|
recs, err := app.files.ListByNode(fileNode.ID)
|
||||||
|
if err != nil || len(recs) == 0 {
|
||||||
|
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
|
||||||
|
}
|
||||||
|
record := recs[0]
|
||||||
|
absPath := filepath.Join(vault, record.Path)
|
||||||
|
content := "Hello, World!"
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the entire tree.
|
||||||
|
if err := app.DeleteNode(parent.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteNode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify trash listing has the file node with trashFsPath set.
|
||||||
|
trash, err := app.ListTrash()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTrash: %v", err)
|
||||||
|
}
|
||||||
|
var fileTrashNode *TrashNodeDTO
|
||||||
|
for i, n := range trash.Nodes {
|
||||||
|
if n.ID == fileNode.ID {
|
||||||
|
fileTrashNode = &trash.Nodes[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileTrashNode == nil {
|
||||||
|
t.Fatalf("file node not found in trash listing")
|
||||||
|
}
|
||||||
|
if fileTrashNode.TrashFsPath == "" {
|
||||||
|
t.Fatalf("file node missing trashFsPath: %+v", fileTrashNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ReadTrashFile works with the precomputed path.
|
||||||
|
readContent, err := app.ReadTrashFile(fileTrashNode.TrashFsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadTrashFile: %v", err)
|
||||||
|
}
|
||||||
|
if readContent != content {
|
||||||
|
t.Fatalf("ReadTrashFile content = %q, want %q", readContent, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ReadTrashFileContent also works (fallback using file records).
|
||||||
|
readContent2, err := app.ReadTrashFileContent(fileNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadTrashFileContent: %v", err)
|
||||||
|
}
|
||||||
|
if readContent2 != content {
|
||||||
|
t.Fatalf("ReadTrashFileContent = %q, want %q", readContent2, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify trashed file records are still in DB.
|
||||||
|
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTrashedByNode: %v", err)
|
||||||
|
}
|
||||||
|
if len(trashedRecs) != 1 {
|
||||||
|
t.Fatalf("ListTrashedByNode = %d records, want 1", len(trashedRecs))
|
||||||
|
}
|
||||||
|
if !trashedRecs[0].Missing {
|
||||||
|
t.Fatalf("expected trashed record to have Missing=true")
|
||||||
|
}
|
||||||
|
if trashedRecs[0].Path == "" {
|
||||||
|
t.Fatalf("expected trashed record to keep original Path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the file node.
|
||||||
|
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
|
||||||
|
t.Fatalf("RestoreTrashNode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the node is active.
|
||||||
|
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
|
||||||
|
t.Fatalf("file node not active after restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file record is restored (missing=0).
|
||||||
|
restoredRecs, err := app.files.ListByNode(fileNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByNode after restore: %v", err)
|
||||||
|
}
|
||||||
|
if len(restoredRecs) != 1 {
|
||||||
|
t.Fatalf("ListByNode after restore = %d records, want 1", len(restoredRecs))
|
||||||
|
}
|
||||||
|
if restoredRecs[0].Missing {
|
||||||
|
t.Fatalf("file record should have Missing=false after restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the physical file content is intact.
|
||||||
|
absRestored := filepath.Join(vault, restoredRecs[0].Path)
|
||||||
|
restoredBytes, err := os.ReadFile(absRestored)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored file: %v", err)
|
||||||
|
}
|
||||||
|
if string(restoredBytes) != content {
|
||||||
|
t.Fatalf("restored file content = %q, want %q", string(restoredBytes), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the trash entry is gone (file was moved back).
|
||||||
|
if _, err := os.Stat(fileTrashNode.TrashFsPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("trash entry should be gone after restore, err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent is also restored because RestoreTrashNode restores the entire
|
||||||
|
// ancestor chain from the requested node up to the root deleted node.
|
||||||
|
if _, err := app.nodes.GetActive(parent.ID); err != nil {
|
||||||
|
t.Fatalf("parent should be active after child restore (ancestor chain): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrashTypeFileInsideFolderRestorePreservesContent(t *testing.T) {
|
||||||
|
app, vault := setupTestApp(t)
|
||||||
|
|
||||||
|
// Create: parent folder → child TypeFile.
|
||||||
|
parent, err := app.CreateNodeFromTemplate("", "ProjectX", "folder.default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent: %v", err)
|
||||||
|
}
|
||||||
|
fileNode, err := app.CreateEmptyFile(parent.ID, "data.csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEmptyFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write content.
|
||||||
|
recs, err := app.files.ListByNode(fileNode.ID)
|
||||||
|
if err != nil || len(recs) == 0 {
|
||||||
|
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
|
||||||
|
}
|
||||||
|
absPath := filepath.Join(vault, recs[0].Path)
|
||||||
|
content := "a,b,c\n1,2,3"
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the whole tree.
|
||||||
|
if err := app.DeleteNode(parent.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteNode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore parent. This moves the directory back from trash but does NOT
|
||||||
|
// restore child nodes (RestoreTrashNode walks ancestor chain, not children).
|
||||||
|
if err := app.RestoreTrashNode(parent.ID); err != nil {
|
||||||
|
t.Fatalf("RestoreTrashNode parent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the parent should be active.
|
||||||
|
if _, err := app.nodes.GetActive(parent.ID); err != nil {
|
||||||
|
t.Fatalf("parent should be active: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := app.nodes.GetActive(fileNode.ID); err == nil {
|
||||||
|
t.Fatalf("child file node should remain deleted (RestoreTrashNode is ancestor-only)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent directory should exist on disk.
|
||||||
|
if _, err := os.Stat(filepath.Join(vault, "ProjectX")); err != nil {
|
||||||
|
t.Fatalf("parent directory should exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now restore the child file node specifically.
|
||||||
|
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
|
||||||
|
t.Fatalf("RestoreTrashNode file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
|
||||||
|
t.Fatalf("file node should be active: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File record should be restored.
|
||||||
|
recs, err = app.files.ListByNode(fileNode.ID)
|
||||||
|
if err != nil || len(recs) == 0 {
|
||||||
|
t.Fatalf("ListByNode after restore: %v (len=%d)", err, len(recs))
|
||||||
|
}
|
||||||
|
if recs[0].Missing {
|
||||||
|
t.Fatalf("file record should not be missing after restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical file content should be intact.
|
||||||
|
absRestored := filepath.Join(vault, recs[0].Path)
|
||||||
|
restoredBytes, err := os.ReadFile(absRestored)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored file: %v", err)
|
||||||
|
}
|
||||||
|
if string(restoredBytes) != content {
|
||||||
|
t.Fatalf("content = %q, want %q", string(restoredBytes), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrashTypeFileMultipleRecords(t *testing.T) {
|
||||||
|
app, vault := setupTestApp(t)
|
||||||
|
|
||||||
|
// Create a TypeFile node with two file records.
|
||||||
|
fileNode, err := app.CreateEmptyFile("", "report.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEmptyFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write content to first record.
|
||||||
|
recs, err := app.files.ListByNode(fileNode.ID)
|
||||||
|
if err != nil || len(recs) == 0 {
|
||||||
|
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
|
||||||
|
}
|
||||||
|
absPath1 := filepath.Join(vault, recs[0].Path)
|
||||||
|
content1 := "version 1"
|
||||||
|
if err := os.WriteFile(absPath1, []byte(content1), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually insert a second file record with its own vault file.
|
||||||
|
now := nowStr()
|
||||||
|
absPath2 := filepath.Join(vault, "report-v2.txt")
|
||||||
|
content2 := "version 2"
|
||||||
|
if err := os.WriteFile(absPath2, []byte(content2), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file 2: %v", err)
|
||||||
|
}
|
||||||
|
_, err = app.db.Exec(
|
||||||
|
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||||
|
"second-record-id", fileNode.ID, "report-v2.txt",
|
||||||
|
"report-v2.txt", "vault", 0, "", "text/plain", now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert second record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the node.
|
||||||
|
if err := app.DeleteNode(fileNode.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteNode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both records are trashed.
|
||||||
|
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTrashedByNode: %v", err)
|
||||||
|
}
|
||||||
|
if len(trashedRecs) != 2 {
|
||||||
|
t.Fatalf("expected 2 trashed records, got %d", len(trashedRecs))
|
||||||
|
}
|
||||||
|
for _, r := range trashedRecs {
|
||||||
|
if !r.Missing {
|
||||||
|
t.Fatalf("record %s should have Missing=true", r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify trash listing has trashFsPath set.
|
||||||
|
trash, err := app.ListTrash()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTrash: %v", err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, n := range trash.Nodes {
|
||||||
|
if n.ID == fileNode.ID {
|
||||||
|
found = true
|
||||||
|
if n.TrashFsPath == "" {
|
||||||
|
t.Fatalf("trashFsPath should be set for file node with multiple records")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("file node not found in trash listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore.
|
||||||
|
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
|
||||||
|
t.Fatalf("RestoreTrashNode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both records should be restored.
|
||||||
|
restoredRecs, err := app.files.ListByNode(fileNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByNode after restore: %v", err)
|
||||||
|
}
|
||||||
|
if len(restoredRecs) != 2 {
|
||||||
|
t.Fatalf("expected 2 restored records, got %d: %+v", len(restoredRecs), restoredRecs)
|
||||||
|
}
|
||||||
|
for _, r := range restoredRecs {
|
||||||
|
if r.Missing {
|
||||||
|
t.Fatalf("record %s should not be missing", r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1397,7 +1397,11 @@
|
||||||
previewError = ''
|
previewError = ''
|
||||||
previewLoading = true
|
previewLoading = true
|
||||||
try {
|
try {
|
||||||
previewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
|
if (node.trashFsPath) {
|
||||||
|
previewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || ''
|
||||||
|
} else {
|
||||||
|
previewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
|
||||||
|
}
|
||||||
const ext = (node.title || '').split('.').pop().toLowerCase()
|
const ext = (node.title || '').split('.').pop().toLowerCase()
|
||||||
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
|
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
|
||||||
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
|
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
|
||||||
|
|
|
||||||
|
|
@ -200,12 +200,25 @@ func (s *Service) Get(id string) (*Record, error) {
|
||||||
return scanRecord(row)
|
return scanRecord(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByNode returns all files linked to a node.
|
// ListByNode returns all active (non-trashed) files linked to a node.
|
||||||
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
|
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||||
created_at,updated_at,last_seen_at,missing
|
created_at,updated_at,last_seen_at,missing
|
||||||
FROM files WHERE node_id = ? ORDER BY created_at`, nodeID)
|
FROM files WHERE node_id = ? AND missing != 1 ORDER BY created_at`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRecords(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrashedByNode returns file records with missing=1 for a given node.
|
||||||
|
func (s *Service) ListTrashedByNode(nodeID string) ([]Record, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||||
|
created_at,updated_at,last_seen_at,missing
|
||||||
|
FROM files WHERE node_id = ? AND missing = 1 ORDER BY created_at`, nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -545,6 +558,32 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trashRecord moves a file record's physical file to .verstak/trash/ and
|
||||||
|
// marks the record as missing=1 so it can be restored later.
|
||||||
|
func (s *Service) trashRecord(r Record) error {
|
||||||
|
if r.StorageMode == "vault" {
|
||||||
|
src, err := s.vaultPath(r.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||||
|
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
||||||
|
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", r.ID+"_"+r.Filename)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(src); os.IsNotExist(statErr) {
|
||||||
|
// File already gone — keep the DB record as-is.
|
||||||
|
} else if err := os.Rename(src, dest); err != nil {
|
||||||
|
return fmt.Errorf("move to trash: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec("UPDATE files SET missing=1, updated_at=? WHERE id=?", now(), r.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) deleteFileRecords(nodeID string) error {
|
func (s *Service) deleteFileRecords(nodeID string) error {
|
||||||
records, err := s.ListByNode(nodeID)
|
records, err := s.ListByNode(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -552,7 +591,7 @@ func (s *Service) deleteFileRecords(nodeID string) error {
|
||||||
}
|
}
|
||||||
var errs []string
|
var errs []string
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
if err := s.DeleteToTrash(r.ID); err != nil {
|
if err := s.trashRecord(r); err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("file %s: %v", r.ID, err))
|
errs = append(errs, fmt.Sprintf("file %s: %v", r.ID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue