913 lines
25 KiB
Go
913 lines
25 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
|
|
}
|
|
dtos := filterContainers(toNodeDTOs(list))
|
|
for i := range dtos {
|
|
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dtos[i].HasChildren = n > 0
|
|
}
|
|
return dtos, nil
|
|
}
|
|
|
|
func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
|
|
list, err := a.nodes.ListChildren(parentID, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dtos := filterContainers(toNodeDTOs(list))
|
|
for i := range dtos {
|
|
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dtos[i].HasChildren = n > 0
|
|
}
|
|
return dtos, nil
|
|
}
|
|
|
|
func filterContainers(dtos []NodeDTO) []NodeDTO {
|
|
out := make([]NodeDTO, 0, len(dtos))
|
|
for _, d := range dtos {
|
|
if isContainerType(d.Type) {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isContainerType(typ string) bool {
|
|
switch typ {
|
|
case "case", "client", "project", "folder", "document", "recipe":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// wouldCreateCycle checks that moving nodeID into newParentID does not create a cycle.
|
|
// It walks from newParentID up toward root: if nodeID is encountered, then newParentID
|
|
// is inside nodeID's subtree, and the move would create a cycle.
|
|
func (a *App) wouldCreateCycle(nodeID, newParentID string) error {
|
|
if nodeID == "" {
|
|
return fmt.Errorf("node ID is empty")
|
|
}
|
|
if newParentID == "" {
|
|
return nil
|
|
}
|
|
current := newParentID
|
|
depth := 0
|
|
for current != "" && depth < 1000 {
|
|
if current == nodeID {
|
|
return fmt.Errorf("cannot move a node into itself or its 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 {
|
|
if nodeID == "" {
|
|
return fmt.Errorf("node ID is required")
|
|
}
|
|
|
|
node, err := a.nodes.GetActive(nodeID)
|
|
if err != nil {
|
|
return fmt.Errorf("node not found: %w", err)
|
|
}
|
|
|
|
// Cannot move into itself
|
|
if nodeID == newParentID {
|
|
return fmt.Errorf("cannot move a node into itself")
|
|
}
|
|
|
|
// Cannot move into descendant (would create cycle)
|
|
if err := a.wouldCreateCycle(nodeID, newParentID); 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)
|
|
}
|
|
if !isContainerType(parent.Type) {
|
|
return fmt.Errorf("target %q is not a container (type: %s)", parent.Title, parent.Type)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|