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:
parent
4f01f2de2e
commit
66c5c81f39
|
|
@ -45,7 +45,7 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
|||
|
||||
seg := templates.SafeDisplayNameToPathSegment(title)
|
||||
if seg == "" {
|
||||
seg = title
|
||||
seg = "node"
|
||||
}
|
||||
|
||||
var parent *nodes.Node
|
||||
|
|
@ -126,7 +126,7 @@ func (a *App) DeleteNode(id string) error {
|
|||
entity = syncsvc.EntityNote
|
||||
targetType = activity.TargetNote
|
||||
evType = activity.TypeNoteDeleted
|
||||
case nodes.TypeFolder:
|
||||
case nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient, nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace:
|
||||
entity = syncsvc.EntityFolder
|
||||
targetType = activity.TargetFolder
|
||||
evType = activity.TypeFolderDeleted
|
||||
|
|
@ -142,19 +142,66 @@ func (a *App) DeleteNode(id string) error {
|
|||
_ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "")
|
||||
_ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil)
|
||||
|
||||
// Move physical folder to trash
|
||||
if n.FsPath != "" {
|
||||
// Handle physical trash based on node type
|
||||
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)
|
||||
if info, err := os.Stat(src); err == nil && info.IsDir() {
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
|
|
@ -163,43 +210,101 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
|
||||
seg := templates.SafeDisplayNameToPathSegment(newTitle)
|
||||
if seg == "" {
|
||||
seg = newTitle
|
||||
seg = "node"
|
||||
}
|
||||
|
||||
oldFsPath := n.FsPath
|
||||
oldPhysPath := filepath.Join(a.vault, oldFsPath)
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
parentFsPath := ""
|
||||
if n.ParentID != nil {
|
||||
p, err := a.nodes.GetActive(*n.ParentID)
|
||||
if err == nil {
|
||||
parentFsPath = p.FsPath
|
||||
if isFolderLike {
|
||||
// Folder-like node: rename physical directory
|
||||
if n.FsPath == "" {
|
||||
return fmt.Errorf("cannot rename node %s (%s): fs_path is empty", n.ID, n.Title)
|
||||
}
|
||||
}
|
||||
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
|
||||
oldFsPath := n.FsPath
|
||||
oldPhysPath := filepath.Join(a.vault, oldFsPath)
|
||||
|
||||
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 {
|
||||
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)
|
||||
// Update file record filename/path
|
||||
rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID)
|
||||
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 targetType string
|
||||
var syncEntity string
|
||||
switch n.Type {
|
||||
case nodes.TypeNote:
|
||||
if n.Type == nodes.TypeNote {
|
||||
evType = activity.TypeNoteUpdated
|
||||
targetType = activity.TargetNote
|
||||
syncEntity = syncsvc.EntityNote
|
||||
case nodes.TypeFile:
|
||||
} else {
|
||||
evType = activity.TypeFileRenamed
|
||||
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.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"fs_path": newFsPath,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range destChildren {
|
||||
if destChildren[i].Title == node.Title {
|
||||
newName := fmt.Sprintf("%s (%d)", node.Title, 2)
|
||||
_ = a.nodes.UpdateTitle(nodeID, newName)
|
||||
break
|
||||
}
|
||||
}
|
||||
isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile
|
||||
|
||||
// Resolve new parent
|
||||
var parent *nodes.Node
|
||||
if newParentID != "" {
|
||||
parent, err = a.nodes.GetActive(newParentID)
|
||||
|
|
@ -263,31 +346,105 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
}
|
||||
}
|
||||
|
||||
seg := templates.SafeDisplayNameToPathSegment(node.Title)
|
||||
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
|
||||
|
||||
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
|
||||
// Resolve name conflicts first
|
||||
nodeTitle := node.Title
|
||||
if parent != nil {
|
||||
destChildren, _ := a.nodes.ListChildren(newParentID, false)
|
||||
for i := range destChildren {
|
||||
if destChildren[i].Title == nodeTitle {
|
||||
nodeTitle = fmt.Sprintf("%s (%d)", nodeTitle, 2)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldPhysPath); err == nil {
|
||||
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||
return fmt.Errorf("move folder: %w", err)
|
||||
// Update title if changed
|
||||
if nodeTitle != node.Title {
|
||||
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 evType string
|
||||
var syncEntity string
|
||||
switch node.Type {
|
||||
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:
|
||||
if isFolderLike {
|
||||
targetType = activity.TargetFolder
|
||||
evType = activity.TypeFolderMoved
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
default:
|
||||
targetType = activity.TargetNode
|
||||
evType = activity.TypeNodeUpdated
|
||||
syncEntity = syncsvc.EntityNode
|
||||
} else if node.Type == nodes.TypeNote {
|
||||
targetType = activity.TargetNote
|
||||
evType = activity.TypeNoteUpdated
|
||||
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.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"fs_path": newFsPath,
|
||||
"fs_path": node.FsPath,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return nil
|
||||
|
|
@ -344,6 +496,9 @@ func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.FsPath == "" {
|
||||
return "", fmt.Errorf("node %s has no physical path", nodeID)
|
||||
}
|
||||
physPath := filepath.Join(a.vault, n.FsPath)
|
||||
return physPath, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,12 +198,6 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
|||
filename := payload.Filename
|
||||
if filename == "" {
|
||||
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 := ""
|
||||
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 == "" {
|
||||
parentFsPath = "spaces"
|
||||
parentFsPath = filepath.Join(".verstak", "remote-inbox")
|
||||
}
|
||||
dest = filepath.Join(a.vault, parentFsPath, filename)
|
||||
payload.Path, _ = filepath.Rel(a.vault, dest)
|
||||
|
|
@ -279,10 +273,11 @@ func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
|||
}
|
||||
|
||||
if storageMode == "vault" {
|
||||
abs, err := syncsvc.SafeVaultPath(a.vault, filePath)
|
||||
clean, err := syncsvc.SafeVaultPath(a.vault, filePath)
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,71 +5,104 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
// VaultCheckResult contains the diagnostic report for vault integrity.
|
||||
type VaultCheckResult struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
NodesWithFsPath int `json:"nodes_with_fs_path"`
|
||||
FilesOnDisk int `json:"files_on_disk"`
|
||||
FilesMissing int `json:"files_missing"`
|
||||
PathEscapeCount int `json:"path_escape_count"`
|
||||
PathMismatchCount int `json:"path_mismatch_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Healthy bool `json:"healthy"`
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
NodesWithFsPath int `json:"nodes_with_fs_path"`
|
||||
FoldersOnDisk int `json:"folders_on_disk"`
|
||||
FilesOnDisk int `json:"files_on_disk"`
|
||||
FilesMissing int `json:"files_missing"`
|
||||
PathEscapeCount int `json:"path_escape_count"`
|
||||
ParentIDEmptyCount int `json:"parent_id_empty_count"`
|
||||
OrphanDescendantCount int `json:"orphan_descendant_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
func (a *App) VaultCheck() (*VaultCheckResult, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
checkNode = func(id string) {
|
||||
n, err := a.nodes.GetActive(id)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("node %s: get: %v", id, err))
|
||||
// Check parent_id consistency
|
||||
for id, n := range allNodes {
|
||||
if !n.IsRoot() && n.ParentID != nil && *n.ParentID == "" {
|
||||
result.ParentIDEmptyCount++
|
||||
result.Errors = append(result.Errors,
|
||||
fmt.Sprintf("node %s (%s): parent_id is empty string, should be nil", id, n.Title))
|
||||
result.Healthy = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check each node
|
||||
for _, n := range allNodes {
|
||||
if n.IsDeleted() {
|
||||
continue
|
||||
}
|
||||
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
|
||||
if n.FsPath != "" {
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
if isFolderLike && n.FsPath != "" {
|
||||
result.NodesWithFsPath++
|
||||
physPath := filepath.Join(a.vault, n.FsPath)
|
||||
rel, err := filepath.Rel(a.vault, physPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
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
|
||||
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.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
|
||||
// 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`)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
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
|
||||
continue
|
||||
}
|
||||
|
|
@ -98,12 +132,14 @@ func (a *App) VaultCheck() (*VaultCheckResult, error) {
|
|||
result.FilesOnDisk++
|
||||
} else {
|
||||
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
|
||||
}
|
||||
return result, nil
|
||||
|
|
|
|||
Loading…
Reference in New Issue