diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index 40217a7..32bf06c 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -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 { diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go index 061ac37..d73a6f0 100644 --- a/cmd/verstak-gui/sync_apply.go +++ b/cmd/verstak-gui/sync_apply.go @@ -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: diff --git a/cmd/verstak-gui/vault_layout_test.go b/cmd/verstak-gui/vault_layout_test.go index 3180370..fdcd0ce 100644 --- a/cmd/verstak-gui/vault_layout_test.go +++ b/cmd/verstak-gui/vault_layout_test.go @@ -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 { diff --git a/internal/core/files/file.go b/internal/core/files/file.go index dc4f420..ff849c4 100644 --- a/internal/core/files/file.go +++ b/internal/core/files/file.go @@ -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