fix: note/file move ops, rename/move atomicity, importDir folder creation
- importDir: create physical folder for imported directories - RenameNode/MoveNode: os.Rename before DB updates (atomicity) - RenameNode note/file: fail if physical file missing - MoveNode: file renames before DB updates - applyRemoteNoteOp: handle OpMove for notes/files - applyRemoteNodeMove: handle notes/files with empty FsPath - MoveNode sync payload: no fs_path for notes/files - Add 7 tests covering all fixes
This commit is contained in:
parent
20a05569ac
commit
7b2a1da529
|
|
@ -185,22 +185,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
newFsPath = rel
|
||||
|
||||
oldTitle := n.Title
|
||||
|
||||
// Check source exists before filesystem rename
|
||||
if _, err := os.Stat(oldPhysPath); err != nil {
|
||||
return fmt.Errorf("source folder not found: %w", err)
|
||||
}
|
||||
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||
return fmt.Errorf("rename folder: %w", err)
|
||||
}
|
||||
|
||||
// Update DB only after successful filesystem rename
|
||||
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
||||
_ = a.nodes.UpdateTitle(nodeID, oldTitle)
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
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
|
||||
|
|
@ -215,10 +223,6 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
}
|
||||
|
||||
// Note/file node: rename the physical file, update file record
|
||||
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect file records first to avoid connection deadlock (SetMaxOpenConns=1)
|
||||
type fileRec struct {
|
||||
id, path, filename string
|
||||
|
|
@ -236,6 +240,15 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
rows.Close()
|
||||
}
|
||||
|
||||
// Collect rename operations without modifying anything yet
|
||||
type renameOp struct {
|
||||
id string
|
||||
oldFilename string
|
||||
oldAbs string
|
||||
newFilename string
|
||||
newRelPath string
|
||||
}
|
||||
var renameOps []renameOp
|
||||
for _, r := range records {
|
||||
if r.path == "" {
|
||||
continue
|
||||
|
|
@ -250,7 +263,6 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
}
|
||||
newFilename = seg + ext
|
||||
} else {
|
||||
// File node: title is the full filename
|
||||
newFilename = newTitle
|
||||
}
|
||||
newRelPath := filepath.Join(dir, newFilename)
|
||||
|
|
@ -276,12 +288,47 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Rename physical file
|
||||
if _, err := os.Stat(oldAbs); err == nil {
|
||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
||||
_, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
|
||||
newFilename, newRelPath, r.id)
|
||||
// Check source file exists
|
||||
if _, err := os.Stat(oldAbs); err != nil {
|
||||
return fmt.Errorf("source file not found for rename: %w", err)
|
||||
}
|
||||
|
||||
renameOps = append(renameOps, renameOp{
|
||||
id: r.id,
|
||||
oldFilename: r.filename,
|
||||
oldAbs: oldAbs,
|
||||
newFilename: newFilename,
|
||||
newRelPath: newRelPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Perform all physical renames first
|
||||
for _, rop := range renameOps {
|
||||
newAbs := filepath.Join(a.vault, rop.newRelPath)
|
||||
if err := os.Rename(rop.oldAbs, newAbs); err != nil {
|
||||
// Rollback completed renames
|
||||
for _, prev := range renameOps {
|
||||
if prev.id == rop.id {
|
||||
break
|
||||
}
|
||||
_ = os.Rename(filepath.Join(a.vault, prev.newRelPath), prev.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("rename file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// All renames succeeded — update DB
|
||||
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||
// Rollback filesystem
|
||||
for _, rop := range renameOps {
|
||||
_ = os.Rename(filepath.Join(a.vault, rop.newRelPath), rop.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, rop := range renameOps {
|
||||
if _, err := a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
|
||||
rop.newFilename, rop.newRelPath, rop.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,77 +411,88 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
// Update parent_id (use nil for root move)
|
||||
// Check source exists and do filesystem rename first
|
||||
if _, err := os.Stat(oldPhysPath); err != nil {
|
||||
return fmt.Errorf("source folder not found: %w", err)
|
||||
}
|
||||
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||
return fmt.Errorf("move folder: %w", err)
|
||||
}
|
||||
|
||||
// Update DB only after successful filesystem rename
|
||||
if newParentID == "" {
|
||||
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldPhysPath); err == nil {
|
||||
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||
return fmt.Errorf("move folder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
node.FsPath = newFsPath
|
||||
} 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
|
||||
type fileMove struct {
|
||||
id, path string
|
||||
}
|
||||
var fileMoves []fileMove
|
||||
// Note/file node: move physical file first, then update DB
|
||||
var fileMoves []fileMoveInfo
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var fm fileMove
|
||||
if err := frows.Scan(&fm.id, &fm.path); err != nil {
|
||||
var fm fileMoveInfo
|
||||
if err := frows.Scan(&fm.id, &fm.oldPath); err != nil {
|
||||
continue
|
||||
}
|
||||
if fm.oldPath == "" {
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(fm.oldPath)
|
||||
fm.newRelPath = filename
|
||||
if parent != nil && parent.FsPath != "" {
|
||||
fm.newRelPath = filepath.Join(parent.FsPath, filename)
|
||||
}
|
||||
fileMoves = append(fileMoves, fm)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
// Perform filesystem moves first
|
||||
for _, fm := range fileMoves {
|
||||
if fm.path == "" {
|
||||
continue
|
||||
oldAbs := filepath.Join(a.vault, fm.oldPath)
|
||||
newAbs := filepath.Join(a.vault, fm.newRelPath)
|
||||
if _, err := os.Stat(oldAbs); err != nil {
|
||||
return fmt.Errorf("source file not found for move: %w", err)
|
||||
}
|
||||
filename := filepath.Base(fm.path)
|
||||
newRelPath := filename
|
||||
if parent != nil && parent.FsPath != "" {
|
||||
newRelPath = filepath.Join(parent.FsPath, filename)
|
||||
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
return fmt.Errorf("move file: %w", err)
|
||||
}
|
||||
oldAbs := filepath.Join(a.vault, fm.path)
|
||||
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, fm.id)
|
||||
}
|
||||
// Update DB only after successful filesystem renames
|
||||
if newParentID == "" {
|
||||
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
|
||||
_ = a.rollbackFileMoves(fileMoves)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
|
||||
_ = a.rollbackFileMoves(fileMoves)
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, fm := range fileMoves {
|
||||
if _, err := a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
|
||||
fm.newRelPath, fm.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -460,15 +518,14 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
syncEntity = syncsvc.EntityFile
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`)
|
||||
var opFsPath string
|
||||
if isFolderLike && node.FsPath != "" {
|
||||
opFsPath = node.FsPath
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
opPayload := map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"fs_path": opFsPath,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if isFolderLike && node.FsPath != "" {
|
||||
opPayload["fs_path"] = node.FsPath
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, opPayload)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -486,6 +543,19 @@ func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
type fileMoveInfo struct {
|
||||
id, oldPath, newRelPath string
|
||||
}
|
||||
|
||||
func (a *App) rollbackFileMoves(moves []fileMoveInfo) error {
|
||||
for _, fm := range moves {
|
||||
oldAbs := filepath.Join(a.vault, fm.oldPath)
|
||||
newAbs := filepath.Join(a.vault, fm.newRelPath)
|
||||
_ = os.Rename(newAbs, oldAbs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -260,18 +260,10 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if payload.FsPath != "" {
|
||||
oldFsPath := n.FsPath
|
||||
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||
payload.FsPath, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isFolderLike && oldFsPath != "" {
|
||||
// Physically move folder
|
||||
oldPhys := filepath.Join(a.vault, oldFsPath)
|
||||
if isFolderLike {
|
||||
// Folder-like node: update fs_path and physically move directory
|
||||
if payload.FsPath != "" && n.FsPath != "" {
|
||||
oldPhys := filepath.Join(a.vault, n.FsPath)
|
||||
newPhys := filepath.Join(a.vault, payload.FsPath)
|
||||
if _, err := os.Stat(oldPhys); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
||||
|
|
@ -279,42 +271,15 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
|||
log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err)
|
||||
}
|
||||
}
|
||||
} else if !isFolderLike {
|
||||
// Move file record path for notes/files
|
||||
type fm struct {
|
||||
id, path string
|
||||
}
|
||||
var fileMoves []fm
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, op.EntityID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var m fm
|
||||
if err := frows.Scan(&m.id, &m.path); err != nil {
|
||||
continue
|
||||
}
|
||||
fileMoves = append(fileMoves, m)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
for _, m := range fileMoves {
|
||||
if m.path == "" {
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(m.path)
|
||||
newRelPath := filepath.Join(payload.FsPath, filename)
|
||||
oldAbs := filepath.Join(a.vault, m.path)
|
||||
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, m.id)
|
||||
}
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||
payload.FsPath, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Note/file: move physical file to new parent's directory
|
||||
return a.moveNodeFiles(n, payload.ParentID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -334,6 +299,8 @@ func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
|||
return a.applyRemoteNoteCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNoteUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNoteMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
|
|
@ -475,6 +442,87 @@ func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteMove(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note move: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
|
||||
n, err := a.nodes.Get(op.EntityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update parent_id
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
||||
parent, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move physical file to new parent's directory
|
||||
return a.moveNodeFiles(n, payload.ParentID)
|
||||
}
|
||||
|
||||
func (a *App) moveNodeFiles(n *nodes.Node, newParentID string) error {
|
||||
var parentFsPath string
|
||||
if newParentID != "" {
|
||||
parent, err := a.nodes.GetActive(newParentID)
|
||||
if err == nil && parent.FsPath != "" {
|
||||
parentFsPath = parent.FsPath
|
||||
}
|
||||
}
|
||||
|
||||
type fileMove struct {
|
||||
id, path string
|
||||
}
|
||||
var fileMoves []fileMove
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, n.ID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var fm fileMove
|
||||
if err := frows.Scan(&fm.id, &fm.path); err != nil {
|
||||
continue
|
||||
}
|
||||
fileMoves = append(fileMoves, fm)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
for _, fm := range fileMoves {
|
||||
if fm.path == "" {
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(fm.path)
|
||||
newRelPath := filename
|
||||
if parentFsPath != "" {
|
||||
newRelPath = filepath.Join(parentFsPath, filename)
|
||||
}
|
||||
oldAbs := filepath.Join(a.vault, fm.path)
|
||||
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, fm.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
|
|
@ -532,6 +535,541 @@ func TestVaultLayout_SyncNodeCreatePreservesFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_ImportEmptyDirCreatesPhysicalFolder(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create a parent folder
|
||||
parent, err := app.CreateNodeFromTemplate("", "Parent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
parentFsPath := parent.FsPath
|
||||
|
||||
// Create an empty temp directory
|
||||
emptyDir := filepath.Join(vault, "emptydir")
|
||||
if err := os.MkdirAll(emptyDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir empty dir: %v", err)
|
||||
}
|
||||
|
||||
// Import the empty directory
|
||||
nodes, err := app.files.AddPathCopy(parent.ID, emptyDir)
|
||||
if err != nil {
|
||||
t.Fatalf("import empty dir: %v", err)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
t.Fatal("expected at least one node from import")
|
||||
}
|
||||
|
||||
// The first node should be the imported folder
|
||||
folderNode := &nodes[0]
|
||||
if folderNode.Type != "folder" {
|
||||
t.Errorf("expected folder type, got %q", folderNode.Type)
|
||||
}
|
||||
|
||||
// Re-read from DB to get updated FsPath (UpdateFsPath is called after Create)
|
||||
folderFromDB, err := app.nodes.Get(folderNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get folder from db: %v", err)
|
||||
}
|
||||
if folderFromDB.FsPath == "" {
|
||||
t.Error("expected non-empty fs_path for imported folder")
|
||||
}
|
||||
|
||||
// Verify physical folder exists on disk
|
||||
expectedPath := filepath.Join(vault, parentFsPath, "emptydir")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("expected folder at %s", expectedPath)
|
||||
}
|
||||
if folderFromDB.FsPath != "" {
|
||||
folderPhysPath := filepath.Join(vault, folderFromDB.FsPath)
|
||||
if _, err := os.Stat(folderPhysPath); os.IsNotExist(err) {
|
||||
t.Errorf("expected physical folder at %s", folderPhysPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_RemoteNoteMoveMovesFile(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create source parent folder
|
||||
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create src parent: %v", err)
|
||||
}
|
||||
// Create dest parent folder
|
||||
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create dst parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a note in src parent directly (simulating remote create)
|
||||
noteNode, fileRec, err := app.notes.Create(srcParent.ID, "RemoteNote", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
// Verify note file exists in source
|
||||
srcFilePath := filepath.Join(vault, fileRec.Path)
|
||||
if _, err := os.Stat(srcFilePath); os.IsNotExist(err) {
|
||||
t.Fatalf("expected note file at %s", srcFilePath)
|
||||
}
|
||||
|
||||
// Simulate remote move op for EntityNote
|
||||
op := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityNote,
|
||||
EntityID: noteNode.ID,
|
||||
OpType: syncsvc.OpMove,
|
||||
PayloadJSON: fmt.Sprintf(`{"parent_id":"%s","updated_at":"%s"}`, dstParent.ID, time.Now().UTC().Format(time.RFC3339)),
|
||||
}
|
||||
if err := app.applyRemoteOp(op); err != nil {
|
||||
t.Fatalf("apply remote note move: %v", err)
|
||||
}
|
||||
|
||||
// Verify node parent_id changed
|
||||
n, err := app.nodes.GetActive(noteNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get moved note: %v", err)
|
||||
}
|
||||
if n.ParentID == nil || *n.ParentID != dstParent.ID {
|
||||
t.Errorf("expected parent_id %s, got %v", dstParent.ID, n.ParentID)
|
||||
}
|
||||
|
||||
// Verify file was moved to destination folder
|
||||
records, _ := app.files.ListByNode(noteNode.ID)
|
||||
if len(records) > 0 {
|
||||
expectedNewPath := filepath.Join(dstParent.FsPath, filepath.Base(records[0].Path))
|
||||
if records[0].Path != expectedNewPath {
|
||||
t.Errorf("expected file path %q, got %q", expectedNewPath, records[0].Path)
|
||||
}
|
||||
destFilePath := filepath.Join(vault, records[0].Path)
|
||||
if _, err := os.Stat(destFilePath); os.IsNotExist(err) {
|
||||
t.Errorf("expected file at destination %s", destFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify old file no longer exists at source
|
||||
if _, err := os.Stat(srcFilePath); !os.IsNotExist(err) {
|
||||
t.Error("expected old file to not exist after move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_RemoteFileMoveMovesPhysicalFile(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create source and destination folders
|
||||
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create src parent: %v", err)
|
||||
}
|
||||
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create dst parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a file node in source parent
|
||||
fileNode, err := app.nodes.Create(&srcParent.ID, "file", "testfile.txt", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create file node: %v", err)
|
||||
}
|
||||
srcFile := filepath.Join(vault, "src.txt")
|
||||
if err := os.WriteFile(srcFile, []byte("test data"), 0o640); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, srcParent.FsPath); err != nil {
|
||||
t.Fatalf("copy into vault: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists in source
|
||||
records, _ := app.files.ListByNode(fileNode.ID)
|
||||
if len(records) == 0 {
|
||||
t.Fatal("expected file records")
|
||||
}
|
||||
oldFilePath := filepath.Join(vault, records[0].Path)
|
||||
if _, err := os.Stat(oldFilePath); os.IsNotExist(err) {
|
||||
t.Fatalf("expected file at %s", oldFilePath)
|
||||
}
|
||||
|
||||
// Simulate remote move op for EntityFile
|
||||
op := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityFile,
|
||||
EntityID: fileNode.ID,
|
||||
OpType: syncsvc.OpMove,
|
||||
PayloadJSON: fmt.Sprintf(`{"parent_id":"%s","updated_at":"%s"}`, dstParent.ID, time.Now().UTC().Format(time.RFC3339)),
|
||||
}
|
||||
if err := app.applyRemoteOp(op); err != nil {
|
||||
t.Fatalf("apply remote file move: %v", err)
|
||||
}
|
||||
|
||||
// Verify node parent_id changed
|
||||
n, err := app.nodes.GetActive(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get moved file: %v", err)
|
||||
}
|
||||
if n.ParentID == nil || *n.ParentID != dstParent.ID {
|
||||
t.Errorf("expected parent_id %s, got %v", dstParent.ID, n.ParentID)
|
||||
}
|
||||
|
||||
// Verify file was moved to destination
|
||||
records2, _ := app.files.ListByNode(fileNode.ID)
|
||||
if len(records2) > 0 {
|
||||
expectedNewPath := filepath.Join(dstParent.FsPath, filepath.Base(records2[0].Path))
|
||||
if records2[0].Path != expectedNewPath {
|
||||
t.Errorf("expected file path %q, got %q", expectedNewPath, records2[0].Path)
|
||||
}
|
||||
destFilePath := filepath.Join(vault, records2[0].Path)
|
||||
if _, err := os.Stat(destFilePath); os.IsNotExist(err) {
|
||||
t.Errorf("expected file at destination %s", destFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify old file no longer exists
|
||||
if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) {
|
||||
t.Error("expected old file to not exist after move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_LocalNoteMoveSyncPayloadCanBeAppliedRemotely(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create source and destination parents
|
||||
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create src parent: %v", err)
|
||||
}
|
||||
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create dst parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a note
|
||||
noteNode, _, err := app.notes.Create(srcParent.ID, "MovedNote", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
// Move the note via MoveNode
|
||||
if err := app.MoveNode(noteNode.ID, dstParent.ID); err != nil {
|
||||
t.Fatalf("move note: %v", err)
|
||||
}
|
||||
|
||||
// Verify local move produced correct sync op
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
|
||||
var moveOp *syncsvc.Op
|
||||
for i := range ops {
|
||||
if ops[i].EntityID == noteNode.ID && ops[i].OpType == syncsvc.OpMove {
|
||||
moveOp = &ops[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if moveOp == nil {
|
||||
t.Fatal("expected move sync op for note")
|
||||
}
|
||||
if moveOp.EntityType != syncsvc.EntityNote {
|
||||
t.Errorf("expected entity type 'note', got %q", moveOp.EntityType)
|
||||
}
|
||||
|
||||
// Verify payload has parent_id but no fs_path
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(moveOp.PayloadJSON), &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if pid, ok := payload["parent_id"]; !ok || pid != dstParent.ID {
|
||||
t.Errorf("expected parent_id %q in payload, got %v", dstParent.ID, pid)
|
||||
}
|
||||
if _, ok := payload["fs_path"]; ok {
|
||||
t.Error("expected no fs_path in note move payload")
|
||||
}
|
||||
|
||||
// Verify note file is at destination
|
||||
noteAtDst := filepath.Join(vault, dstParent.FsPath, "MovedNote.md")
|
||||
if _, err := os.Stat(noteAtDst); os.IsNotExist(err) {
|
||||
t.Errorf("expected note at %s", noteAtDst)
|
||||
}
|
||||
|
||||
// Now simulate receiving this same op on another device
|
||||
app2, _ := setupTestApp(t)
|
||||
defer app2.db.Close()
|
||||
defer os.RemoveAll(app2.vault)
|
||||
|
||||
// First create the same nodes on app2 (simulating prior sync)
|
||||
// Create src and dst parents
|
||||
_, err = app2.CreateNodeFromTemplate("", "SrcParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("app2 create src: %v", err)
|
||||
}
|
||||
_, err = app2.CreateNodeFromTemplate("", "DstParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("app2 create dst: %v", err)
|
||||
}
|
||||
// Create the same note via remote create
|
||||
createOp := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityNote,
|
||||
EntityID: noteNode.ID,
|
||||
OpType: syncsvc.OpCreate,
|
||||
PayloadJSON: moveOp.PayloadJSON, // approximate
|
||||
}
|
||||
// We need proper create payload
|
||||
app2NoteCreatePayload := fmt.Sprintf(
|
||||
`{"node_id":"%s","file_id":"%s","format":"markdown","content":"# MovedNote\n\n","filename":"MovedNote.md","path":"%s","created_at":"%s","updated_at":"%s"}`,
|
||||
noteNode.ID, "test-file-id-1", filepath.Join(srcParent.FsPath, "MovedNote.md"),
|
||||
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
|
||||
createOp.PayloadJSON = app2NoteCreatePayload
|
||||
|
||||
if err := app2.applyRemoteOp(createOp); err != nil {
|
||||
t.Fatalf("app2 apply remote create: %v", err)
|
||||
}
|
||||
|
||||
// Now apply the move op on app2
|
||||
if err := app2.applyRemoteOp(*moveOp); err != nil {
|
||||
t.Fatalf("app2 apply remote move: %v", err)
|
||||
}
|
||||
|
||||
// Verify node moved on app2
|
||||
app2Node, err := app2.nodes.GetActive(noteNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("app2 get moved node: %v", err)
|
||||
}
|
||||
if app2Node.ParentID == nil || *app2Node.ParentID != dstParent.ID {
|
||||
t.Errorf("app2: expected parent_id %s, got %v", dstParent.ID, app2Node.ParentID)
|
||||
}
|
||||
|
||||
// Verify file moved on app2
|
||||
app2Records, _ := app2.files.ListByNode(noteNode.ID)
|
||||
if len(app2Records) > 0 {
|
||||
destPath := filepath.Join(app2.vault, app2Records[0].Path)
|
||||
if _, err := os.Stat(destPath); os.IsNotExist(err) {
|
||||
t.Errorf("app2: expected file at %s", destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_LocalFileMoveSyncPayloadCanBeAppliedRemotely(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create source and destination parents
|
||||
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create src parent: %v", err)
|
||||
}
|
||||
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create dst parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a file node
|
||||
fileNode, err := app.nodes.Create(&srcParent.ID, "file", "movable.txt", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create file node: %v", err)
|
||||
}
|
||||
srcFile := filepath.Join(vault, "src_movable.txt")
|
||||
if err := os.WriteFile(srcFile, []byte("movable data"), 0o640); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, srcParent.FsPath); err != nil {
|
||||
t.Fatalf("copy into vault: %v", err)
|
||||
}
|
||||
|
||||
// Move the file via MoveNode
|
||||
if err := app.MoveNode(fileNode.ID, dstParent.ID); err != nil {
|
||||
t.Fatalf("move file: %v", err)
|
||||
}
|
||||
|
||||
// Verify local move produced correct sync op
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
var moveOp *syncsvc.Op
|
||||
for i := range ops {
|
||||
if ops[i].EntityID == fileNode.ID && ops[i].OpType == syncsvc.OpMove {
|
||||
moveOp = &ops[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if moveOp == nil {
|
||||
t.Fatal("expected move sync op for file")
|
||||
}
|
||||
if moveOp.EntityType != syncsvc.EntityFile {
|
||||
t.Errorf("expected entity type 'file', got %q", moveOp.EntityType)
|
||||
}
|
||||
|
||||
// Verify payload
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(moveOp.PayloadJSON), &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if _, ok := payload["fs_path"]; ok {
|
||||
t.Error("expected no fs_path in file move payload")
|
||||
}
|
||||
|
||||
// Now simulate receiving this op on another device
|
||||
app2, vault2 := setupTestApp(t)
|
||||
defer app2.db.Close()
|
||||
defer os.RemoveAll(vault2)
|
||||
|
||||
// Create same nodes on app2
|
||||
_, err = app2.CreateNodeFromTemplate("", "SrcParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("app2 create src: %v", err)
|
||||
}
|
||||
_, err = app2.CreateNodeFromTemplate("", "DstParent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("app2 create dst: %v", err)
|
||||
}
|
||||
|
||||
// Create the file node via remote create
|
||||
app2CreatePayload := fmt.Sprintf(
|
||||
`{"node_id":"%s","type":"file","title":"movable.txt","slug":"movable-txt","parent_id":"%s","filename":"movable.txt","path":"%s","storage_mode":"vault","size":12,"file_id":"test-file-id-2","created_at":"%s","updated_at":"%s"}`,
|
||||
fileNode.ID, srcParent.ID, filepath.Join(srcParent.FsPath, "movable.txt"),
|
||||
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
|
||||
createOp := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityFile,
|
||||
EntityID: fileNode.ID,
|
||||
OpType: syncsvc.OpCreate,
|
||||
PayloadJSON: app2CreatePayload,
|
||||
}
|
||||
if err := app2.applyRemoteOp(createOp); err != nil {
|
||||
t.Fatalf("app2 apply remote create: %v", err)
|
||||
}
|
||||
// Create the physical file on app2
|
||||
os.MkdirAll(filepath.Join(vault2, srcParent.FsPath), 0o750)
|
||||
os.WriteFile(filepath.Join(vault2, srcParent.FsPath, "movable.txt"), []byte("movable data"), 0o640)
|
||||
|
||||
// Apply move op on app2
|
||||
if err := app2.applyRemoteOp(*moveOp); err != nil {
|
||||
t.Fatalf("app2 apply remote move: %v", err)
|
||||
}
|
||||
|
||||
// Verify node moved on app2
|
||||
app2Node, err := app2.nodes.GetActive(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("app2 get moved node: %v", err)
|
||||
}
|
||||
if app2Node.ParentID == nil || *app2Node.ParentID != dstParent.ID {
|
||||
t.Errorf("app2: expected parent_id %s, got %v", dstParent.ID, app2Node.ParentID)
|
||||
}
|
||||
|
||||
// Verify file moved on app2
|
||||
app2Records, _ := app2.files.ListByNode(fileNode.ID)
|
||||
if len(app2Records) > 0 {
|
||||
destPath := filepath.Join(vault2, app2Records[0].Path)
|
||||
if _, err := os.Stat(destPath); os.IsNotExist(err) {
|
||||
t.Errorf("app2: expected file at %s", destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_RenameNoteFileReturnsErrorIfPhysicalFileMissing(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Parent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a note
|
||||
noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
// Delete the physical file
|
||||
physPath := filepath.Join(app.vault, fileRec.Path)
|
||||
if err := os.Remove(physPath); err != nil {
|
||||
t.Fatalf("remove note file: %v", err)
|
||||
}
|
||||
|
||||
// Rename should fail because physical file is missing
|
||||
if err := app.RenameNode(noteNode.ID, "RenamedNote"); err == nil {
|
||||
t.Error("expected error when renaming note with missing physical file")
|
||||
}
|
||||
|
||||
// Create a file node
|
||||
fileNode, err := app.nodes.Create(&parent.ID, "file", "testfile.txt", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create file node: %v", err)
|
||||
}
|
||||
srcFile := filepath.Join(app.vault, "src_for_test.txt")
|
||||
if err := os.WriteFile(srcFile, []byte("data"), 0o640); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, parent.FsPath); err != nil {
|
||||
t.Fatalf("copy into vault: %v", err)
|
||||
}
|
||||
|
||||
// Delete the physical file
|
||||
fileRecs, _ := app.files.ListByNode(fileNode.ID)
|
||||
if len(fileRecs) > 0 {
|
||||
filePhysPath := filepath.Join(app.vault, fileRecs[0].Path)
|
||||
if err := os.Remove(filePhysPath); err != nil {
|
||||
t.Fatalf("remove file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rename should fail
|
||||
if err := app.RenameNode(fileNode.ID, "renamed.txt"); err == nil {
|
||||
t.Error("expected error when renaming file with missing physical file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_FolderRenameDoesNotUpdateDBIfOsRenameFails(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create a parent folder that we'll make read-only
|
||||
parent, err := app.CreateNodeFromTemplate("", "TestRoot", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a child folder inside TestRoot
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "OriginalName", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
oldTitle := child.Title
|
||||
oldFsPath := child.FsPath
|
||||
|
||||
// Verify physical folder exists
|
||||
if _, err := os.Stat(filepath.Join(vault, oldFsPath)); os.IsNotExist(err) {
|
||||
t.Fatalf("expected child folder at %s", oldFsPath)
|
||||
}
|
||||
|
||||
// Make TestRoot read-only to cause os.Rename to fail
|
||||
parentPhysPath := filepath.Join(vault, parent.FsPath)
|
||||
if err := os.Chmod(parentPhysPath, 0o555); err != nil {
|
||||
t.Fatalf("chmod parent: %v", err)
|
||||
}
|
||||
// Restore permissions on cleanup
|
||||
defer os.Chmod(parentPhysPath, 0o755)
|
||||
|
||||
// Rename should fail because parent is read-only
|
||||
if err := app.RenameNode(child.ID, "RenamedName"); err == nil {
|
||||
t.Error("expected error when renaming folder in read-only directory")
|
||||
}
|
||||
|
||||
// Verify DB was NOT updated
|
||||
n, err := app.nodes.GetActive(child.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get child: %v", err)
|
||||
}
|
||||
if n.Title != oldTitle {
|
||||
t.Errorf("expected title %q unchanged, got %q", oldTitle, n.Title)
|
||||
}
|
||||
if n.FsPath != oldFsPath {
|
||||
t.Errorf("expected fs_path %q unchanged, got %q", oldFsPath, n.FsPath)
|
||||
}
|
||||
|
||||
// Verify physical folder still at old location
|
||||
if _, err := os.Stat(filepath.Join(vault, oldFsPath)); os.IsNotExist(err) {
|
||||
t.Error("expected original folder to still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func listNames(entries []os.DirEntry) []string {
|
||||
|
|
|
|||
|
|
@ -526,6 +526,11 @@ func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool)
|
|||
}
|
||||
_ = s.nodes.UpdateFsPath(folderNode.ID, folderFsPath)
|
||||
|
||||
physPath := filepath.Join(s.vaultRoot, folderFsPath)
|
||||
if err := os.MkdirAll(physPath, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create folder for imported dir: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
Loading…
Reference in New Issue