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
|
newFsPath = rel
|
||||||
|
|
||||||
oldTitle := n.Title
|
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 {
|
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
||||||
|
_ = a.nodes.UpdateTitle(nodeID, oldTitle)
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
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 := ""
|
pid := ""
|
||||||
if n.ParentID != nil {
|
if n.ParentID != nil {
|
||||||
pid = *n.ParentID
|
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
|
// 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)
|
// Collect file records first to avoid connection deadlock (SetMaxOpenConns=1)
|
||||||
type fileRec struct {
|
type fileRec struct {
|
||||||
id, path, filename string
|
id, path, filename string
|
||||||
|
|
@ -236,6 +240,15 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
rows.Close()
|
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 {
|
for _, r := range records {
|
||||||
if r.path == "" {
|
if r.path == "" {
|
||||||
continue
|
continue
|
||||||
|
|
@ -250,7 +263,6 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
}
|
}
|
||||||
newFilename = seg + ext
|
newFilename = seg + ext
|
||||||
} else {
|
} else {
|
||||||
// File node: title is the full filename
|
|
||||||
newFilename = newTitle
|
newFilename = newTitle
|
||||||
}
|
}
|
||||||
newRelPath := filepath.Join(dir, newFilename)
|
newRelPath := filepath.Join(dir, newFilename)
|
||||||
|
|
@ -276,12 +288,47 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename physical file
|
// Check source file exists
|
||||||
if _, err := os.Stat(oldAbs); err == nil {
|
if _, err := os.Stat(oldAbs); err != nil {
|
||||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
return fmt.Errorf("source file not found for rename: %w", err)
|
||||||
_, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
|
}
|
||||||
newFilename, newRelPath, r.id)
|
|
||||||
|
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)
|
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||||
newFsPath = rel
|
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 newParentID == "" {
|
||||||
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
|
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
|
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
||||||
|
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||||
return err
|
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
|
node.FsPath = newFsPath
|
||||||
} else {
|
} else {
|
||||||
// Note/file node: update parent_id and move physical file
|
// Note/file node: move physical file first, then update DB
|
||||||
if newParentID == "" {
|
var fileMoves []fileMoveInfo
|
||||||
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
|
|
||||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
|
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
|
||||||
if ferr == nil {
|
if ferr == nil {
|
||||||
for frows.Next() {
|
for frows.Next() {
|
||||||
var fm fileMove
|
var fm fileMoveInfo
|
||||||
if err := frows.Scan(&fm.id, &fm.path); err != nil {
|
if err := frows.Scan(&fm.id, &fm.oldPath); err != nil {
|
||||||
continue
|
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)
|
fileMoves = append(fileMoves, fm)
|
||||||
}
|
}
|
||||||
frows.Close()
|
frows.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform filesystem moves first
|
||||||
for _, fm := range fileMoves {
|
for _, fm := range fileMoves {
|
||||||
if fm.path == "" {
|
oldAbs := filepath.Join(a.vault, fm.oldPath)
|
||||||
continue
|
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)
|
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
||||||
newRelPath := filename
|
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||||
if parent != nil && parent.FsPath != "" {
|
return fmt.Errorf("move file: %w", err)
|
||||||
newRelPath = filepath.Join(parent.FsPath, filename)
|
|
||||||
}
|
}
|
||||||
oldAbs := filepath.Join(a.vault, fm.path)
|
}
|
||||||
newAbs := filepath.Join(a.vault, newRelPath)
|
|
||||||
|
|
||||||
if _, err := os.Stat(oldAbs); err == nil {
|
// Update DB only after successful filesystem renames
|
||||||
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
if newParentID == "" {
|
||||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
|
||||||
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
|
_ = a.rollbackFileMoves(fileMoves)
|
||||||
newRelPath, fm.id)
|
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
|
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+`"}`)
|
||||||
var opFsPath string
|
opPayload := map[string]interface{}{
|
||||||
if isFolderLike && node.FsPath != "" {
|
|
||||||
opFsPath = node.FsPath
|
|
||||||
}
|
|
||||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
|
||||||
"parent_id": newParentID,
|
"parent_id": newParentID,
|
||||||
"fs_path": opFsPath,
|
|
||||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -486,6 +543,19 @@ func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
|
||||||
return result, nil
|
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) {
|
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
||||||
n, err := a.nodes.GetActive(nodeID)
|
n, err := a.nodes.GetActive(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -260,18 +260,10 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.FsPath != "" {
|
if isFolderLike {
|
||||||
oldFsPath := n.FsPath
|
// Folder-like node: update fs_path and physically move directory
|
||||||
|
if payload.FsPath != "" && n.FsPath != "" {
|
||||||
if _, err := a.db.Exec(
|
oldPhys := filepath.Join(a.vault, n.FsPath)
|
||||||
`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)
|
|
||||||
newPhys := filepath.Join(a.vault, payload.FsPath)
|
newPhys := filepath.Join(a.vault, payload.FsPath)
|
||||||
if _, err := os.Stat(oldPhys); err == nil {
|
if _, err := os.Stat(oldPhys); err == nil {
|
||||||
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
_ = 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)
|
log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !isFolderLike {
|
if _, err := a.db.Exec(
|
||||||
// Move file record path for notes/files
|
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||||
type fm struct {
|
payload.FsPath, now, op.EntityID); err != nil {
|
||||||
id, path string
|
return err
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Note/file: move physical file to new parent's directory
|
||||||
|
return a.moveNodeFiles(n, payload.ParentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -334,6 +299,8 @@ func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
||||||
return a.applyRemoteNoteCreate(op)
|
return a.applyRemoteNoteCreate(op)
|
||||||
case syncsvc.OpUpdate:
|
case syncsvc.OpUpdate:
|
||||||
return a.applyRemoteNoteUpdate(op)
|
return a.applyRemoteNoteUpdate(op)
|
||||||
|
case syncsvc.OpMove:
|
||||||
|
return a.applyRemoteNoteMove(op)
|
||||||
case syncsvc.OpDelete:
|
case syncsvc.OpDelete:
|
||||||
return a.applyRemoteNodeDelete(op)
|
return a.applyRemoteNodeDelete(op)
|
||||||
}
|
}
|
||||||
|
|
@ -475,6 +442,87 @@ func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
||||||
return nil
|
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 {
|
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
|
||||||
switch op.OpType {
|
switch op.OpType {
|
||||||
case syncsvc.OpCreate:
|
case syncsvc.OpCreate:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/activity"
|
"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 ---
|
// --- helpers ---
|
||||||
|
|
||||||
func listNames(entries []os.DirEntry) []string {
|
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)
|
_ = 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)
|
entries, err := os.ReadDir(sourcePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue