fix: стабилизация vault layout — rename/move/delete note/file vs folder разведены, sync apply без spaces/, VaultCheck усилен

- RenameNode/DeleteNode/MoveNode: note/file и folder-like nodes
  обрабатываются по-разному (file record vs физическая папка)
- DeleteNode: рекурсивный soft-delete всех descendants
- SafeVaultPath возвращает clean relative, filepath.Join в sync_apply.go
- Fallback spaces/ → .verstak/remote-inbox в applyRemoteNoteCreate
- VaultCheck: проверка parent_id != nil, orphan descendants,
  fs_path folder на диске
This commit is contained in:
mirivlad 2026-06-02 16:36:43 +08:00
parent 4f01f2de2e
commit 66c5c81f39
3 changed files with 329 additions and 143 deletions

View File

@ -45,7 +45,7 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
seg := templates.SafeDisplayNameToPathSegment(title) seg := templates.SafeDisplayNameToPathSegment(title)
if seg == "" { if seg == "" {
seg = title seg = "node"
} }
var parent *nodes.Node var parent *nodes.Node
@ -126,7 +126,7 @@ func (a *App) DeleteNode(id string) error {
entity = syncsvc.EntityNote entity = syncsvc.EntityNote
targetType = activity.TargetNote targetType = activity.TargetNote
evType = activity.TypeNoteDeleted evType = activity.TypeNoteDeleted
case nodes.TypeFolder: case nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient, nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace:
entity = syncsvc.EntityFolder entity = syncsvc.EntityFolder
targetType = activity.TargetFolder targetType = activity.TargetFolder
evType = activity.TypeFolderDeleted evType = activity.TypeFolderDeleted
@ -142,19 +142,66 @@ func (a *App) DeleteNode(id string) error {
_ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "") _ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "")
_ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil) _ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil)
// Move physical folder to trash // Handle physical trash based on node type
if n.FsPath != "" { isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
if isFolderLike && n.FsPath != "" {
// Move entire folder to trash
src := filepath.Join(a.vault, n.FsPath) src := filepath.Join(a.vault, n.FsPath)
if info, err := os.Stat(src); err == nil && info.IsDir() { if info, err := os.Stat(src); err == nil && info.IsDir() {
trashDir := filepath.Join(a.vault, ".verstak", "trash") trashDir := filepath.Join(a.vault, ".verstak", "trash")
_ = os.MkdirAll(trashDir, 0o750) _ = os.MkdirAll(trashDir, 0o750)
_ = os.Rename(src, filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))) trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
_ = os.Rename(src, trashPath)
} }
// Recursively soft-delete all descendants
a.softDeleteDescendants(id)
return a.nodes.SoftDelete(id)
} }
// For notes/files: move associated file to trash, then soft-delete
if err := a.deleteNodeFileRecord(id); err != nil {
// Log but continue with soft-delete
}
return a.nodes.SoftDelete(id) return a.nodes.SoftDelete(id)
} }
// softDeleteDescendants recursively soft-deletes all descendant nodes.
func (a *App) softDeleteDescendants(parentID string) {
children, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return
}
for _, child := range children {
a.softDeleteDescendants(child.ID)
_ = a.nodes.SoftDelete(child.ID)
}
}
// deleteNodeFileRecord moves the file record for a node to trash.
func (a *App) deleteNodeFileRecord(nodeID string) error {
rows, err := a.db.Query(`SELECT id, path, storage_mode FROM files WHERE node_id=?`, nodeID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id, path, mode string
if err := rows.Scan(&id, &path, &mode); err != nil {
continue
}
if mode == "vault" {
src := filepath.Join(a.vault, path)
trashDir := filepath.Join(a.vault, ".verstak", "trash")
_ = os.MkdirAll(trashDir, 0o750)
_ = os.Rename(src, filepath.Join(trashDir, id+"_"+filepath.Base(path)))
}
}
_, err = a.db.Exec(`DELETE FROM files WHERE node_id=?`, nodeID)
return err
}
func (a *App) RenameNode(nodeID, newTitle string) error { func (a *App) RenameNode(nodeID, newTitle string) error {
n, err := a.nodes.GetActive(nodeID) n, err := a.nodes.GetActive(nodeID)
if err != nil { if err != nil {
@ -163,43 +210,101 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
seg := templates.SafeDisplayNameToPathSegment(newTitle) seg := templates.SafeDisplayNameToPathSegment(newTitle)
if seg == "" { if seg == "" {
seg = newTitle seg = "node"
} }
oldFsPath := n.FsPath isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
oldPhysPath := filepath.Join(a.vault, oldFsPath)
parentFsPath := "" if isFolderLike {
if n.ParentID != nil { // Folder-like node: rename physical directory
p, err := a.nodes.GetActive(*n.ParentID) if n.FsPath == "" {
if err == nil { return fmt.Errorf("cannot rename node %s (%s): fs_path is empty", n.ID, n.Title)
parentFsPath = p.FsPath
} }
}
newFsPath := seg
if parentFsPath != "" {
newFsPath = filepath.Join(parentFsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath) oldFsPath := n.FsPath
rel, _ := filepath.Rel(a.vault, newPhysPath) oldPhysPath := filepath.Join(a.vault, oldFsPath)
newFsPath = rel
oldTitle := n.Title parentFsPath := ""
if n.ParentID != nil {
p, err := a.nodes.GetActive(*n.ParentID)
if err == nil {
parentFsPath = p.FsPath
}
}
newFsPath := seg
if parentFsPath != "" {
newFsPath = filepath.Join(parentFsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath)
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
oldTitle := n.Title
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err
}
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
return err
}
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
return err
}
if _, err := os.Stat(oldPhysPath); err == nil {
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("rename folder: %w", err)
}
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderRenamed, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle,
"fs_path": newFsPath,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
// Note/file node: rename the physical file, update file record
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil { if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err return err
} }
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
return err
}
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
return err
}
if _, err := os.Stat(oldPhysPath); err == nil { // Update file record filename/path
if err := os.Rename(oldPhysPath, newPhysPath); err != nil { rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID)
return fmt.Errorf("rename folder: %w", err) if err != nil {
return nil // non-fatal
}
defer rows.Close()
for rows.Next() {
var fileID, filePath, oldFilename string
if err := rows.Scan(&fileID, &filePath, &oldFilename); err != nil {
continue
}
if filePath == "" {
continue
}
oldAbs := filepath.Join(a.vault, filePath)
ext := filepath.Ext(oldFilename)
newFilename := seg + ext
dir := filepath.Dir(filePath)
newRelPath := filepath.Join(dir, newFilename)
newAbs := filepath.Join(a.vault, newRelPath)
// Rename physical file
if _, err := os.Stat(oldAbs); err == nil {
// Check for collision
if _, err := os.Stat(newAbs); os.IsNotExist(err) {
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
newFilename, newRelPath, fileID)
}
}
} }
} }
@ -209,52 +314,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
} }
var evType string var evType string
var targetType string var targetType string
var syncEntity string if n.Type == nodes.TypeNote {
switch n.Type {
case nodes.TypeNote:
evType = activity.TypeNoteUpdated evType = activity.TypeNoteUpdated
targetType = activity.TargetNote targetType = activity.TargetNote
syncEntity = syncsvc.EntityNote } else {
case nodes.TypeFile:
evType = activity.TypeFileRenamed evType = activity.TypeFileRenamed
targetType = activity.TargetFile targetType = activity.TargetFile
syncEntity = syncsvc.EntityFile
case nodes.TypeFolder:
evType = activity.TypeFolderRenamed
targetType = activity.TargetFolder
syncEntity = syncsvc.EntityFolder
default:
evType = activity.TypeNodeUpdated
targetType = activity.TargetNode
syncEntity = syncsvc.EntityNode
} }
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "")
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{ _ = a.sync.RecordOp(syncsvc.EntityNote, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle, "title": newTitle,
"fs_path": newFsPath,
"updated_at": time.Now().UTC().Format(time.RFC3339), "updated_at": time.Now().UTC().Format(time.RFC3339),
}) })
return nil return nil
} }
func (a *App) MoveNode(nodeID, newParentID string) error { func (a *App) MoveNode(nodeID, newParentID string) error {
destChildren, err := a.nodes.ListChildren(newParentID, false)
if err != nil {
return err
}
node, err := a.nodes.GetActive(nodeID) node, err := a.nodes.GetActive(nodeID)
if err != nil { if err != nil {
return err return err
} }
for i := range destChildren { isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile
if destChildren[i].Title == node.Title {
newName := fmt.Sprintf("%s (%d)", node.Title, 2)
_ = a.nodes.UpdateTitle(nodeID, newName)
break
}
}
// Resolve new parent
var parent *nodes.Node var parent *nodes.Node
if newParentID != "" { if newParentID != "" {
parent, err = a.nodes.GetActive(newParentID) parent, err = a.nodes.GetActive(newParentID)
@ -263,31 +346,105 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
} }
} }
seg := templates.SafeDisplayNameToPathSegment(node.Title) // Resolve name conflicts first
newFsPath := seg nodeTitle := node.Title
if parent != nil && parent.FsPath != "" { if parent != nil {
newFsPath = filepath.Join(parent.FsPath, seg) destChildren, _ := a.nodes.ListChildren(newParentID, false)
} for i := range destChildren {
newPhysPath := filepath.Join(a.vault, newFsPath) if destChildren[i].Title == nodeTitle {
newPhysPath = templates.UniquePath(newPhysPath) nodeTitle = fmt.Sprintf("%s (%d)", nodeTitle, 2)
rel, _ := filepath.Rel(a.vault, newPhysPath) break
newFsPath = rel }
}
oldPhysPath := filepath.Join(a.vault, node.FsPath)
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
return err
}
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
return err
}
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
return err
} }
if _, err := os.Stat(oldPhysPath); err == nil { // Update title if changed
if err := os.Rename(oldPhysPath, newPhysPath); err != nil { if nodeTitle != node.Title {
return fmt.Errorf("move folder: %w", err) if err := a.nodes.UpdateTitle(nodeID, nodeTitle); err != nil {
return err
}
node.Title = nodeTitle
}
if isFolderLike {
// Folder-like node: move physical directory
if node.FsPath == "" {
return fmt.Errorf("cannot move node %s (%s): fs_path is empty", node.ID, node.Title)
}
seg := templates.SafeDisplayNameToPathSegment(node.Title)
oldPhysPath := filepath.Join(a.vault, node.FsPath)
newFsPath := seg
if parent != nil && parent.FsPath != "" {
newFsPath = filepath.Join(parent.FsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath)
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
// Update parent_id (use nil for root move)
if newParentID == "" {
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
return err
}
} else {
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
return err
}
}
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
return err
}
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
return err
}
if _, err := os.Stat(oldPhysPath); err == nil {
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("move folder: %w", err)
}
}
} else {
// Note/file node: update parent_id and move physical file
if newParentID == "" {
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
return err
}
} else {
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
return err
}
}
// Update file record path to reflect new parent
if parent != nil && parent.FsPath != "" {
rows, err := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
if err == nil {
defer rows.Close()
for rows.Next() {
var fileID, filePath string
if err := rows.Scan(&fileID, &filePath); err != nil {
continue
}
if filePath == "" {
continue
}
filename := filepath.Base(filePath)
newRelPath := filepath.Join(parent.FsPath, filename)
oldAbs := filepath.Join(a.vault, filePath)
newAbs := filepath.Join(a.vault, newRelPath)
if _, err := os.Stat(oldAbs); err == nil {
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
newRelPath, fileID)
}
}
}
}
} }
} }
@ -298,28 +455,23 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
var targetType string var targetType string
var evType string var evType string
var syncEntity string var syncEntity string
switch node.Type { if isFolderLike {
case nodes.TypeNote:
targetType = activity.TargetNote
evType = activity.TypeNoteUpdated
syncEntity = syncsvc.EntityNote
case nodes.TypeFile:
targetType = activity.TargetFile
evType = activity.TypeFileMoved
syncEntity = syncsvc.EntityFile
case nodes.TypeFolder:
targetType = activity.TargetFolder targetType = activity.TargetFolder
evType = activity.TypeFolderMoved evType = activity.TypeFolderMoved
syncEntity = syncsvc.EntityFolder syncEntity = syncsvc.EntityFolder
default: } else if node.Type == nodes.TypeNote {
targetType = activity.TargetNode targetType = activity.TargetNote
evType = activity.TypeNodeUpdated evType = activity.TypeNoteUpdated
syncEntity = syncsvc.EntityNode syncEntity = syncsvc.EntityNote
} else {
targetType = activity.TargetFile
evType = activity.TypeFileMoved
syncEntity = syncsvc.EntityFile
} }
_ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`) _ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`)
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{ _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
"parent_id": newParentID, "parent_id": newParentID,
"fs_path": newFsPath, "fs_path": node.FsPath,
"updated_at": time.Now().UTC().Format(time.RFC3339), "updated_at": time.Now().UTC().Format(time.RFC3339),
}) })
return nil return nil
@ -344,6 +496,9 @@ func (a *App) OpenNodeFolder(nodeID string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if n.FsPath == "" {
return "", fmt.Errorf("node %s has no physical path", nodeID)
}
physPath := filepath.Join(a.vault, n.FsPath) physPath := filepath.Join(a.vault, n.FsPath)
return physPath, nil return physPath, nil
} }

View File

@ -198,12 +198,6 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
filename := payload.Filename filename := payload.Filename
if filename == "" { if filename == "" {
filename = payload.NodeID[:8] + ".md" filename = payload.NodeID[:8] + ".md"
} else {
cleanFilename, err := syncsvc.SafeVaultPath(a.vault, filename)
if err != nil {
return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err)
}
filename = cleanFilename
} }
parentFsPath := "" parentFsPath := ""
if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil { if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil {
@ -212,7 +206,7 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
} }
} }
if parentFsPath == "" { if parentFsPath == "" {
parentFsPath = "spaces" parentFsPath = filepath.Join(".verstak", "remote-inbox")
} }
dest = filepath.Join(a.vault, parentFsPath, filename) dest = filepath.Join(a.vault, parentFsPath, filename)
payload.Path, _ = filepath.Rel(a.vault, dest) payload.Path, _ = filepath.Rel(a.vault, dest)
@ -279,10 +273,11 @@ func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
} }
if storageMode == "vault" { if storageMode == "vault" {
abs, err := syncsvc.SafeVaultPath(a.vault, filePath) clean, err := syncsvc.SafeVaultPath(a.vault, filePath)
if err != nil { if err != nil {
return fmt.Errorf("unsafe vault path in note update: %w", err) return fmt.Errorf("unsafe vault path in note update: %w", err)
} }
abs := filepath.Join(a.vault, clean)
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil { if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
return err return err
} }

View File

@ -5,71 +5,104 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"verstak/internal/core/nodes"
) )
// VaultCheckResult contains the diagnostic report for vault integrity.
type VaultCheckResult struct { type VaultCheckResult struct {
TotalNodes int `json:"total_nodes"` TotalNodes int `json:"total_nodes"`
TotalFiles int `json:"total_files"` TotalFiles int `json:"total_files"`
NodesWithFsPath int `json:"nodes_with_fs_path"` NodesWithFsPath int `json:"nodes_with_fs_path"`
FilesOnDisk int `json:"files_on_disk"` FoldersOnDisk int `json:"folders_on_disk"`
FilesMissing int `json:"files_missing"` FilesOnDisk int `json:"files_on_disk"`
PathEscapeCount int `json:"path_escape_count"` FilesMissing int `json:"files_missing"`
PathMismatchCount int `json:"path_mismatch_count"` PathEscapeCount int `json:"path_escape_count"`
Errors []string `json:"errors,omitempty"` ParentIDEmptyCount int `json:"parent_id_empty_count"`
Details []string `json:"details,omitempty"` OrphanDescendantCount int `json:"orphan_descendant_count"`
Healthy bool `json:"healthy"` Errors []string `json:"errors,omitempty"`
Details []string `json:"details,omitempty"`
Healthy bool `json:"healthy"`
} }
func (a *App) VaultCheck() (*VaultCheckResult, error) { func (a *App) VaultCheck() (*VaultCheckResult, error) {
result := &VaultCheckResult{Healthy: true} result := &VaultCheckResult{Healthy: true}
// Check all root nodes // Build a set of all node IDs for ancestor check
allNodes := make(map[string]*nodes.Node)
roots, err := a.nodes.ListRoots(true) roots, err := a.nodes.ListRoots(true)
if err != nil { if err != nil {
return nil, fmt.Errorf("list roots: %w", err) return nil, fmt.Errorf("list roots: %w", err)
} }
var collectDescendants func(id string)
collectDescendants = func(id string) {
n, err := a.nodes.Get(id)
if err == nil {
allNodes[n.ID] = n
}
children, _ := a.nodes.ListChildren(id, true)
for _, c := range children {
allNodes[c.ID] = &c
collectDescendants(c.ID)
}
}
for _, n := range roots {
allNodes[n.ID] = &n
collectDescendants(n.ID)
}
var checkNode func(id string) // Check parent_id consistency
checkNode = func(id string) { for id, n := range allNodes {
n, err := a.nodes.GetActive(id) if !n.IsRoot() && n.ParentID != nil && *n.ParentID == "" {
if err != nil { result.ParentIDEmptyCount++
result.Errors = append(result.Errors, fmt.Sprintf("node %s: get: %v", id, err)) result.Errors = append(result.Errors,
fmt.Sprintf("node %s (%s): parent_id is empty string, should be nil", id, n.Title))
result.Healthy = false result.Healthy = false
return }
}
// Check each node
for _, n := range allNodes {
if n.IsDeleted() {
continue
} }
result.TotalNodes++ result.TotalNodes++
// Check if ancestor is deleted
if n.ParentID != nil && *n.ParentID != "" {
if parent, ok := allNodes[*n.ParentID]; ok && parent.IsDeleted() {
result.OrphanDescendantCount++
result.Errors = append(result.Errors,
fmt.Sprintf("node %s (%s) is active but parent %s is deleted", n.ID, n.Title, *n.ParentID))
result.Healthy = false
}
}
// Check fs_path // Check fs_path
if n.FsPath != "" { isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
if isFolderLike && n.FsPath != "" {
result.NodesWithFsPath++ result.NodesWithFsPath++
physPath := filepath.Join(a.vault, n.FsPath) physPath := filepath.Join(a.vault, n.FsPath)
rel, err := filepath.Rel(a.vault, physPath) rel, err := filepath.Rel(a.vault, physPath)
if err != nil || strings.HasPrefix(rel, "..") { if err != nil || strings.HasPrefix(rel, "..") {
result.PathEscapeCount++ result.PathEscapeCount++
result.Errors = append(result.Errors, fmt.Sprintf("node %s (%s): fs_path escapes vault: %s", n.ID, n.Title, n.FsPath)) result.Errors = append(result.Errors,
fmt.Sprintf("node %s (%s): fs_path escapes vault: %s", n.ID, n.Title, n.FsPath))
result.Healthy = false result.Healthy = false
return continue
} }
if _, err := os.Stat(physPath); os.IsNotExist(err) { if info, err := os.Stat(physPath); err == nil {
if info.IsDir() {
result.FoldersOnDisk++
}
} else {
result.FilesMissing++ result.FilesMissing++
result.Details = append(result.Details, fmt.Sprintf("node %s (%s): folder missing on disk: %s", n.ID, n.Title, physPath)) result.Details = append(result.Details,
fmt.Sprintf("node %s (%s): folder missing on disk: %s", n.ID, n.Title, physPath))
} }
} }
// Check children
children, _ := a.nodes.ListChildren(n.ID, true)
for _, c := range children {
checkNode(c.ID)
}
}
for _, n := range roots {
checkNode(n.ID)
} }
// Check file records // Check file records
// We'll query files table and verify each vault-mode file exists
rows, err := a.db.Query(`SELECT id, node_id, path, storage_mode FROM files`) rows, err := a.db.Query(`SELECT id, node_id, path, storage_mode FROM files`)
if err != nil { if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err)) result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err))
@ -90,7 +123,8 @@ func (a *App) VaultCheck() (*VaultCheckResult, error) {
rel, err := filepath.Rel(a.vault, absPath) rel, err := filepath.Rel(a.vault, absPath)
if err != nil || strings.HasPrefix(rel, "..") { if err != nil || strings.HasPrefix(rel, "..") {
result.PathEscapeCount++ result.PathEscapeCount++
result.Errors = append(result.Errors, fmt.Sprintf("file %s: path escapes vault: %s", id, path)) result.Errors = append(result.Errors,
fmt.Sprintf("file %s: path escapes vault: %s", id, path))
result.Healthy = false result.Healthy = false
continue continue
} }
@ -98,12 +132,14 @@ func (a *App) VaultCheck() (*VaultCheckResult, error) {
result.FilesOnDisk++ result.FilesOnDisk++
} else { } else {
result.FilesMissing++ result.FilesMissing++
result.Details = append(result.Details, fmt.Sprintf("file %s (%s): missing on disk: %s", id, path, absPath)) result.Details = append(result.Details,
fmt.Sprintf("file %s (%s): missing on disk: %s", id, path, absPath))
} }
} }
} }
if result.FilesMissing > 0 { if result.FilesMissing > 0 || result.PathEscapeCount > 0 ||
result.ParentIDEmptyCount > 0 || result.OrphanDescendantCount > 0 {
result.Healthy = false result.Healthy = false
} }
return result, nil return result, nil