package main import ( "fmt" "os" "path/filepath" "strings" "time" "verstak/internal/core/activity" "verstak/internal/core/nodes" syncsvc "verstak/internal/core/sync" "verstak/internal/core/templates" "verstak/internal/core/util" ) func (a *App) ListWorkspaceTree() ([]NodeDTO, error) { list, err := a.nodes.ListRoots(false) if err != nil { return nil, err } return toNodeDTOs(list), nil } func (a *App) ListChildren(parentID string) ([]NodeDTO, error) { list, err := a.nodes.ListChildren(parentID, false) if err != nil { return nil, err } return toNodeDTOs(list), nil } func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) { n, err := a.nodes.GetActive(nodeID) if err != nil { return nil, err } dto := toNodeDTO(n) return &dto, nil } func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) { tmpl, ok := a.templates.Get(templateID) if !ok { return nil, fmt.Errorf("template %q not found", templateID) } seg := templates.SafeDisplayNameToPathSegment(title) if seg == "" { seg = "node" } var parent *nodes.Node var parentFsPath string if parentID != "" { var err error parent, err = a.nodes.GetActive(parentID) if err != nil { return nil, fmt.Errorf("parent not found: %w", err) } parentFsPath = parent.FsPath } fsPath := seg if parentFsPath != "" { fsPath = filepath.Join(parentFsPath, seg) } physPath := filepath.Join(a.vault, fsPath) physPath = templates.UniquePath(physPath) rel, _ := filepath.Rel(a.vault, physPath) fsPath = rel if _, err := syncsvc.SafeVaultPath(a.vault, fsPath); err != nil { return nil, fmt.Errorf("path safety: %w", err) } var pID *string if parentID != "" { pID = &parentID } sortOrder := 0 n, err := a.nodes.Create(pID, tmpl.Type, title, sortOrder, tmpl.ID, fsPath) if err != nil { return nil, fmt.Errorf("create node: %w", err) } if err := os.MkdirAll(physPath, 0o755); err != nil { _ = a.nodes.SoftDelete(n.ID) return nil, fmt.Errorf("create folder: %w", err) } // Create child nodes for default files and folders with rollback on failure. nowRFC := time.Now().UTC().Format(time.RFC3339) type childInfo struct { id string file bool } var created []childInfo rollbackChildren := func() { for i := len(created) - 1; i >= 0; i-- { _ = a.nodes.SoftDelete(created[i].id) } } for _, df := range tmpl.DefaultFiles { fpath := filepath.Join(physPath, df.Path) if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil { rollbackChildren() return nil, fmt.Errorf("create directory for %s: %w", df.Path, err) } fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path)) if fileTitle == "" { fileTitle = "Overview" } childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "") if childErr != nil { rollbackChildren() return nil, fmt.Errorf("create child node for %s: %w", df.Path, childErr) } created = append(created, childInfo{id: childNode.ID, file: true}) content := fmt.Sprintf("# %s\n\n", title) if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil { rollbackChildren() return nil, fmt.Errorf("write file %s: %w", df.Path, err) } relPath, _ := filepath.Rel(a.vault, fpath) fi, _ := os.Stat(fpath) size := int64(0) if fi != nil { size = fi.Size() } fileID := util.UUID7() _, _ = a.db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`, fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC) _, _ = a.db.Exec( `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, childNode.ID, fileID, "markdown") _ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "") _ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{ "node_id": childNode.ID, "parent_id": n.ID, "title": fileTitle, "file_id": fileID, "format": "markdown", "content": content, "filename": filepath.Base(fpath), "path": relPath, "created_at": nowRFC, "updated_at": nowRFC, }) } for _, folderName := range tmpl.DefaultFolders { folderSeg := templates.SafeDisplayNameToPathSegment(folderName) if folderSeg == "" { folderSeg = "folder" } childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "") if childErr != nil { rollbackChildren() return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr) } created = append(created, childInfo{id: childNode.ID, file: false}) childFsPath := folderSeg if fsPath != "" { childFsPath = filepath.Join(fsPath, folderSeg) } childPhysPath := filepath.Join(a.vault, childFsPath) childPhysPath = templates.UniquePath(childPhysPath) childRel, _ := filepath.Rel(a.vault, childPhysPath) childFsPath = childRel _ = a.nodes.UpdateFsPath(childNode.ID, childFsPath) if err := os.MkdirAll(childPhysPath, 0o755); err != nil { rollbackChildren() return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err) } _ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "") _ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode)) } pid := "" if parentID != "" { pid = parentID } _ = a.activity.Record(pid, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, `{"template":"`+templateID+`"}`) _ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n)) dto := toNodeDTO(n) return &dto, nil } func (a *App) DeleteNode(id string) error { n, err := a.nodes.GetActive(id) if err != nil { return a.nodes.SoftDelete(id) } pid := "" if n.ParentID != nil { pid = *n.ParentID } var entity string var targetType string var evType string switch n.Type { case nodes.TypeNote: entity = syncsvc.EntityNote targetType = activity.TargetNote evType = activity.TypeNoteDeleted case nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient, nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace: entity = syncsvc.EntityFolder targetType = activity.TargetFolder evType = activity.TypeFolderDeleted case nodes.TypeFile: entity = syncsvc.EntityFile targetType = activity.TargetFile evType = activity.TypeFileDeleted default: entity = syncsvc.EntityNode targetType = activity.TargetNode evType = activity.TypeNodeDeleted } _ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "") _ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil) return a.files.DeleteNodeAndChildren(id) } func (a *App) RenameNode(nodeID, newTitle string) error { n, err := a.nodes.GetActive(nodeID) if err != nil { return err } seg := templates.SafeDisplayNameToPathSegment(newTitle) if seg == "" { seg = "node" } isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile if isFolderLike { return a.renameFolderNode(nodeID, n, newTitle, seg) } return a.renameNoteFileNode(nodeID, n, newTitle, seg) } // renameFolderNode renames a folder-like node atomically: FS first, then DB transaction. func (a *App) renameFolderNode(nodeID string, n *nodes.Node, newTitle, seg string) error { if n.FsPath == "" { return fmt.Errorf("cannot rename node %s (%s): fs_path is empty", n.ID, n.Title) } oldFsPath := n.FsPath oldPhysPath := filepath.Join(a.vault, oldFsPath) oldTitle := n.Title parentFsPath := "" if n.ParentID != nil { if p, err := a.nodes.GetActive(*n.ParentID); err == nil { parentFsPath = p.FsPath } } newFsPath := seg if parentFsPath != "" { newFsPath = filepath.Join(parentFsPath, seg) } newPhysPath := filepath.Join(a.vault, newFsPath) newPhysPath = templates.UniquePath(newPhysPath) rel, _ := filepath.Rel(a.vault, newPhysPath) newFsPath = rel if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil { return fmt.Errorf("path safety: %w", err) } // Pre-collect all descendant fs_path updates (before transaction, to avoid deadlock). type pathUp struct{ id, path string } var updates []pathUp var walk func(string, string) walk = func(id, p string) { updates = append(updates, pathUp{id, p}) children, _ := a.nodes.ListChildren(id, false) for _, c := range children { cseg := templates.SafeDisplayNameToPathSegment(c.Title) walk(c.ID, filepath.Join(p, cseg)) } } walk(nodeID, newFsPath) // 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) } // Atomic DB transaction: title + own fs_path + all descendant fs_paths nowT := time.Now().UTC().Format(time.RFC3339) slug := nodes.Slugify(newTitle) tx, err := a.db.Begin() if err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if _, err := tx.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, newTitle, slug, nowT, nodeID); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return err } for _, u := range updates { if _, err := tx.Exec( `UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, u.path, nowT, u.id); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return err } } if err := tx.Commit(); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return fmt.Errorf("commit tx: %w", err) } pid := "" if n.ParentID != nil { pid = *n.ParentID } _ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderRenamed, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) _ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpUpdate, map[string]interface{}{ "title": newTitle, "fs_path": newFsPath, "updated_at": time.Now().UTC().Format(time.RFC3339), }) return nil } // renameNoteFileNode renames a note or file node atomically: FS first, then DB transaction. func (a *App) renameNoteFileNode(nodeID string, n *nodes.Node, newTitle, seg string) error { // Collect file records before any mutations (avoid deadlock with SetMaxOpenConns=1). type fileRec struct { id, path, filename string } var records []fileRec rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID) if err == nil { for rows.Next() { var r fileRec if err := rows.Scan(&r.id, &r.path, &r.filename); err != nil { continue } records = append(records, r) } rows.Close() } // Compute new filenames and paths (no FS modifications yet). type renameOp struct { id string oldAbs string newAbs string newFilename string newPath string } var renameOps []renameOp for _, r := range records { if r.path == "" { continue } oldAbs := filepath.Join(a.vault, r.path) dir := filepath.Dir(r.path) var newFilename string if n.Type == nodes.TypeNote { ext := filepath.Ext(r.filename) if ext == "" { ext = ".md" } newFilename = seg + ext } else { newFilename = newTitle } newPath := filepath.Join(dir, newFilename) newAbs := filepath.Join(a.vault, newPath) // Check for collision if _, err := os.Stat(newAbs); err == nil { ext := filepath.Ext(newFilename) base := strings.TrimSuffix(newFilename, ext) baseSeg := templates.SafeDisplayNameToPathSegment(base) if baseSeg == "" { baseSeg = "renamed" } for i := 2; ; i++ { candidate := fmt.Sprintf("%s (%d)%s", baseSeg, i, ext) candidatePath := filepath.Join(dir, candidate) if _, err := os.Stat(filepath.Join(a.vault, candidatePath)); os.IsNotExist(err) { newFilename = candidate newPath = candidatePath newAbs = filepath.Join(a.vault, newPath) break } } } 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, oldAbs: oldAbs, newAbs: newAbs, newFilename: newFilename, newPath: newPath, }) } // Perform all physical renames first successCount := 0 for i, rop := range renameOps { if err := os.Rename(rop.oldAbs, rop.newAbs); err != nil { // Rollback completed FS renames for j := 0; j < i; j++ { _ = os.Rename(renameOps[j].newAbs, renameOps[j].oldAbs) } return fmt.Errorf("rename file: %w", err) } successCount++ } // Atomic DB transaction: title + all file record updates nowT := time.Now().UTC().Format(time.RFC3339) slug := nodes.Slugify(newTitle) tx, err := a.db.Begin() if err != nil { for _, rop := range renameOps[:successCount] { _ = os.Rename(rop.newAbs, rop.oldAbs) } return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if _, err := tx.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, newTitle, slug, nowT, nodeID); err != nil { for _, rop := range renameOps[:successCount] { _ = os.Rename(rop.newAbs, rop.oldAbs) } return err } for _, rop := range renameOps { if _, err := tx.Exec( `UPDATE files SET filename=?, path=? WHERE id=?`, rop.newFilename, rop.newPath, rop.id); err != nil { for _, rop2 := range renameOps[:successCount] { _ = os.Rename(rop2.newAbs, rop2.oldAbs) } return err } } if err := tx.Commit(); err != nil { for _, rop := range renameOps[:successCount] { _ = os.Rename(rop.newAbs, rop.oldAbs) } return fmt.Errorf("commit tx: %w", err) } pid := "" if n.ParentID != nil { pid = *n.ParentID } var evType string var targetType string var syncEntity string if n.Type == nodes.TypeNote { evType = activity.TypeNoteUpdated targetType = activity.TargetNote syncEntity = syncsvc.EntityNote } else { evType = activity.TypeFileRenamed targetType = activity.TargetFile syncEntity = syncsvc.EntityFile } _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "") _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{ "title": newTitle, "updated_at": nowT, }) return nil } func (a *App) isDescendant(ancestorID, nodeID string) error { if nodeID == "" || ancestorID == "" { return nil } current := nodeID depth := 0 for current != "" && depth < 1000 { if current == ancestorID { return fmt.Errorf("cannot move a node into its own descendant") } n, err := a.nodes.Get(current) if err != nil || n.ParentID == nil { return nil } current = *n.ParentID depth++ } return nil } func (a *App) MoveNode(nodeID, newParentID string) error { node, err := a.nodes.GetActive(nodeID) if err != nil { return err } // Prevent moving node into its own descendant if newParentID != "" { if err := a.isDescendant(newParentID, nodeID); err != nil { return err } } isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile // Resolve new parent var parent *nodes.Node if newParentID != "" { parent, err = a.nodes.GetActive(newParentID) if err != nil { return fmt.Errorf("new parent not found: %w", err) } } // Compute new title (name conflict resolution) but don't commit yet. nodeTitle := node.Title titleChanged := false if parent != nil { destChildren, _ := a.nodes.ListChildren(newParentID, false) for i := range destChildren { if destChildren[i].Title == nodeTitle && destChildren[i].ID != nodeID { nodeTitle = fmt.Sprintf("%s (%d)", nodeTitle, 2) titleChanged = true break } } } if isFolderLike { return a.moveFolderNode(nodeID, node, parent, newParentID, nodeTitle, titleChanged) } return a.moveNoteFileNode(nodeID, node, parent, newParentID, nodeTitle, titleChanged) } // moveFolderNode moves a folder-like node atomically: FS first, then DB transaction. func (a *App) moveFolderNode(nodeID string, node *nodes.Node, parent *nodes.Node, newParentID, nodeTitle string, titleChanged bool) error { if node.FsPath == "" { return fmt.Errorf("cannot move node %s (%s): fs_path is empty", node.ID, node.Title) } oldFsPath := node.FsPath oldPhysPath := filepath.Join(a.vault, oldFsPath) seg := templates.SafeDisplayNameToPathSegment(nodeTitle) newFsPath := seg if parent != nil && parent.FsPath != "" { newFsPath = filepath.Join(parent.FsPath, seg) } newPhysPath := filepath.Join(a.vault, newFsPath) newPhysPath = templates.UniquePath(newPhysPath) rel, _ := filepath.Rel(a.vault, newPhysPath) newFsPath = rel if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil { return fmt.Errorf("path safety: %w", err) } // Pre-collect all descendant fs_path updates (before transaction). type pathUp struct{ id, path string } var updates []pathUp var walk func(string, string) walk = func(id, p string) { updates = append(updates, pathUp{id, p}) children, _ := a.nodes.ListChildren(id, false) for _, c := range children { cseg := templates.SafeDisplayNameToPathSegment(c.Title) walk(c.ID, filepath.Join(p, cseg)) } } walk(nodeID, newFsPath) // 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) } // Atomic DB transaction: title + parent_id + all fs_paths nowT := time.Now().UTC().Format(time.RFC3339) tx, err := a.db.Begin() if err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if titleChanged { slug := nodes.Slugify(nodeTitle) if _, err := tx.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nodeTitle, slug, nowT, nodeID); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return err } } var parentVal interface{} if newParentID != "" { parentVal = newParentID } if _, err := tx.Exec( `UPDATE nodes SET parent_id=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, parentVal, nowT, nodeID); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return err } // Touch new parent if newParentID != "" { _, _ = tx.Exec(`UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nowT, newParentID) } // Touch old parent if different if node.ParentID != nil && *node.ParentID != newParentID { _, _ = tx.Exec(`UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nowT, *node.ParentID) } for _, u := range updates { if _, err := tx.Exec( `UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, u.path, nowT, u.id); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return err } } if err := tx.Commit(); err != nil { _ = os.Rename(newPhysPath, oldPhysPath) return fmt.Errorf("commit tx: %w", err) } // Update node.ParentID for activity/sync below if newParentID == "" { node.ParentID = nil } else { node.ParentID = &newParentID } node.FsPath = newFsPath pid := "" if node.ParentID != nil { pid = *node.ParentID } _ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderMoved, nodeTitle, `{"to":"`+newParentID+`"}`) _ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpMove, map[string]interface{}{ "parent_id": newParentID, "fs_path": newFsPath, "updated_at": nowT, }) return nil } // moveNoteFileNode moves a note/file atomically: FS first, then DB transaction. func (a *App) moveNoteFileNode(nodeID string, node *nodes.Node, parent *nodes.Node, newParentID, nodeTitle string, titleChanged bool) error { // Collect file records before any mutations. type fileMove struct { id string oldPath string oldAbs string newRelPath string newAbs string } var fileMoves []fileMove 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.oldPath); err != nil { continue } if fm.oldPath == "" { continue } fm.oldAbs = filepath.Join(a.vault, fm.oldPath) filename := filepath.Base(fm.oldPath) fm.newRelPath = filename if parent != nil && parent.FsPath != "" { fm.newRelPath = filepath.Join(parent.FsPath, filename) } fm.newAbs = filepath.Join(a.vault, fm.newRelPath) fileMoves = append(fileMoves, fm) } frows.Close() } // Perform filesystem moves first (with rollback on partial failure). for i, fm := range fileMoves { if _, err := os.Stat(fm.oldAbs); err != nil { for j := 0; j < i; j++ { _ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs) } return fmt.Errorf("source file not found for move: %w", err) } _ = os.MkdirAll(filepath.Dir(fm.newAbs), 0o750) if err := os.Rename(fm.oldAbs, fm.newAbs); err != nil { for j := 0; j < i; j++ { _ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs) } return fmt.Errorf("move file: %w", err) } } // Atomic DB transaction: title + parent_id + file paths nowT := time.Now().UTC().Format(time.RFC3339) tx, err := a.db.Begin() if err != nil { for _, fm := range fileMoves { _ = os.Rename(fm.newAbs, fm.oldAbs) } return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if titleChanged { slug := nodes.Slugify(nodeTitle) if _, err := tx.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nodeTitle, slug, nowT, nodeID); err != nil { for _, fm := range fileMoves { _ = os.Rename(fm.newAbs, fm.oldAbs) } return err } } var parentVal interface{} if newParentID != "" { parentVal = newParentID } if _, err := tx.Exec( `UPDATE nodes SET parent_id=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, parentVal, nowT, nodeID); err != nil { for _, fm := range fileMoves { _ = os.Rename(fm.newAbs, fm.oldAbs) } return err } for _, fm := range fileMoves { if _, err := tx.Exec(`UPDATE files SET path=? WHERE id=?`, fm.newRelPath, fm.id); err != nil { for _, fm2 := range fileMoves { _ = os.Rename(fm2.newAbs, fm2.oldAbs) } return err } } if err := tx.Commit(); err != nil { for _, fm := range fileMoves { _ = os.Rename(fm.newAbs, fm.oldAbs) } return fmt.Errorf("commit tx: %w", err) } if newParentID == "" { node.ParentID = nil } else { node.ParentID = &newParentID } pid := "" if node.ParentID != nil { pid = *node.ParentID } var targetType string var evType string var syncEntity string if node.Type == nodes.TypeNote { targetType = activity.TargetNote evType = activity.TypeNoteUpdated syncEntity = syncsvc.EntityNote } else { targetType = activity.TargetFile evType = activity.TypeFileMoved syncEntity = syncsvc.EntityFile } _ = a.activity.Record(pid, targetType, nodeID, "", evType, nodeTitle, `{"to":"`+newParentID+`"}`) _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{ "parent_id": newParentID, "updated_at": nowT, }) return nil } func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) { list := a.templates.Enabled() result := make([]TemplateDTO, len(list)) for i, t := range list { result[i] = TemplateDTO{ ID: t.ID, Title: t.Title, Type: t.Type, Icon: t.Icon, } } 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 { return "", err } if n.FsPath == "" { return "", fmt.Errorf("node %s has no physical path", nodeID) } physPath := filepath.Join(a.vault, n.FsPath) return physPath, nil }