verstak/cmd/verstak-gui/bindings_nodes.go

673 lines
18 KiB
Go

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 (proper DB nodes + file records)
nowRFC := time.Now().UTC().Format(time.RFC3339)
for _, df := range tmpl.DefaultFiles {
fpath := filepath.Join(physPath, df.Path)
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
continue
}
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 {
continue
}
content := fmt.Sprintf("# %s\n\n", title)
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
_ = a.nodes.SoftDelete(childNode.ID)
continue
}
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,
})
}
// Create child nodes for default folders (proper DB nodes + physical folders)
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 {
continue
}
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 {
_ = a.nodes.SoftDelete(childNode.ID)
continue
}
_ = 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 {
// Folder-like node: rename physical directory
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)
parentFsPath := ""
if n.ParentID != nil {
p, err := a.nodes.GetActive(*n.ParentID)
if err == nil {
parentFsPath = p.FsPath
}
}
newFsPath := seg
if parentFsPath != "" {
newFsPath = filepath.Join(parentFsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath)
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
return fmt.Errorf("path safety: %w", err)
}
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
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderRenamed, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle,
"fs_path": newFsPath,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
// Note/file node: rename the physical file, update file record
// Collect file records first to avoid connection deadlock (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()
}
// 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
}
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
}
newRelPath := filepath.Join(dir, newFilename)
newAbs := filepath.Join(a.vault, newRelPath)
// Check for collision — generate unique name if needed
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 n := 2; ; n++ {
candidate := fmt.Sprintf("%s (%d)%s", baseSeg, n, ext)
candidatePath := filepath.Join(dir, candidate)
if _, err := os.Stat(filepath.Join(a.vault, candidatePath)); os.IsNotExist(err) {
newFilename = candidate
newRelPath = candidatePath
newAbs = filepath.Join(a.vault, newRelPath)
break
}
}
}
// 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
}
}
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": time.Now().UTC().Format(time.RFC3339),
})
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)
}
}
// Resolve name conflicts first
nodeTitle := node.Title
if parent != nil {
destChildren, _ := a.nodes.ListChildren(newParentID, false)
for i := range destChildren {
if destChildren[i].Title == nodeTitle {
nodeTitle = fmt.Sprintf("%s (%d)", nodeTitle, 2)
break
}
}
}
// Update title if changed
if nodeTitle != node.Title {
if err := a.nodes.UpdateTitle(nodeID, nodeTitle); err != nil {
return err
}
node.Title = nodeTitle
}
if isFolderLike {
// Folder-like node: move physical directory
if node.FsPath == "" {
return fmt.Errorf("cannot move node %s (%s): fs_path is empty", node.ID, node.Title)
}
seg := templates.SafeDisplayNameToPathSegment(node.Title)
oldPhysPath := filepath.Join(a.vault, node.FsPath)
newFsPath := seg
if parent != nil && parent.FsPath != "" {
newFsPath = filepath.Join(parent.FsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath)
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
return fmt.Errorf("path safety: %w", err)
}
// 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
}
node.FsPath = newFsPath
} else {
// 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 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 {
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)
}
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err != nil {
return fmt.Errorf("move file: %w", err)
}
}
// 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
}
}
}
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
var targetType string
var evType string
var syncEntity string
if isFolderLike {
targetType = activity.TargetFolder
evType = activity.TypeFolderMoved
syncEntity = syncsvc.EntityFolder
} else 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, node.Title, `{"to":"`+newParentID+`"}`)
opPayload := map[string]interface{}{
"parent_id": newParentID,
"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
}
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
}