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)
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
}

View File

@ -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
}

View File

@ -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