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)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue