package main import ( "fmt" "os" "path/filepath" "time" "verstak/internal/core/activity" "verstak/internal/core/nodes" "verstak/internal/core/templates" syncsvc "verstak/internal/core/sync" ) 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 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 { return nil, fmt.Errorf("create folder: %w", err) } for _, df := range tmpl.DefaultFiles { fpath := filepath.Join(physPath, df.Path) if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil { continue } content := fmt.Sprintf("# %s\n\n", title) _ = os.WriteFile(fpath, []byte(content), 0o640) } for _, folder := range tmpl.DefaultFolders { fpath := filepath.Join(physPath, folder) _ = os.MkdirAll(fpath, 0o755) } 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) // Handle physical trash based on node type isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile if isFolderLike && n.FsPath != "" { // Move entire folder to trash src := filepath.Join(a.vault, n.FsPath) if info, err := os.Stat(src); err == nil && info.IsDir() { trashDir := filepath.Join(a.vault, ".verstak", "trash") _ = os.MkdirAll(trashDir, 0o750) trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) _ = os.Rename(src, trashPath) } // Recursively soft-delete all descendants a.softDeleteDescendants(id) return a.nodes.SoftDelete(id) } // For notes/files: move associated file to trash, then soft-delete if err := a.deleteNodeFileRecord(id); err != nil { // Log but continue with soft-delete } return a.nodes.SoftDelete(id) } // softDeleteDescendants recursively soft-deletes all descendant nodes. func (a *App) softDeleteDescendants(parentID string) { children, err := a.nodes.ListChildren(parentID, false) if err != nil { return } for _, child := range children { a.softDeleteDescendants(child.ID) _ = a.nodes.SoftDelete(child.ID) } } // deleteNodeFileRecord moves the file record for a node to trash. func (a *App) deleteNodeFileRecord(nodeID string) error { rows, err := a.db.Query(`SELECT id, path, storage_mode FROM files WHERE node_id=?`, nodeID) if err != nil { return err } defer rows.Close() for rows.Next() { var id, path, mode string if err := rows.Scan(&id, &path, &mode); err != nil { continue } if mode == "vault" { src := filepath.Join(a.vault, path) trashDir := filepath.Join(a.vault, ".verstak", "trash") _ = os.MkdirAll(trashDir, 0o750) _ = os.Rename(src, filepath.Join(trashDir, id+"_"+filepath.Base(path))) } } _, err = a.db.Exec(`DELETE FROM files WHERE node_id=?`, nodeID) return err } 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 oldTitle := n.Title if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil { return err } if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil { return err } if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil { return err } if _, err := os.Stat(oldPhysPath); err == nil { if err := os.Rename(oldPhysPath, newPhysPath); err != nil { return fmt.Errorf("rename folder: %w", err) } } pid := "" if n.ParentID != nil { pid = *n.ParentID } _ = 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 if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil { return err } // Update file record filename/path rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID) if err != nil { return nil // non-fatal } defer rows.Close() for rows.Next() { var fileID, filePath, oldFilename string if err := rows.Scan(&fileID, &filePath, &oldFilename); err != nil { continue } if filePath == "" { continue } oldAbs := filepath.Join(a.vault, filePath) ext := filepath.Ext(oldFilename) newFilename := seg + ext dir := filepath.Dir(filePath) newRelPath := filepath.Join(dir, newFilename) newAbs := filepath.Join(a.vault, newRelPath) // Rename physical file if _, err := os.Stat(oldAbs); err == nil { // Check for collision if _, err := os.Stat(newAbs); os.IsNotExist(err) { if err := os.Rename(oldAbs, newAbs); err == nil { _, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`, newFilename, newRelPath, fileID) } } } } pid := "" if n.ParentID != nil { pid = *n.ParentID } var evType string var targetType string if n.Type == nodes.TypeNote { evType = activity.TypeNoteUpdated targetType = activity.TargetNote } else { evType = activity.TypeFileRenamed targetType = activity.TargetFile } _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "") _ = a.sync.RecordOp(syncsvc.EntityNote, nodeID, syncsvc.OpUpdate, map[string]interface{}{ "title": newTitle, "updated_at": time.Now().UTC().Format(time.RFC3339), }) return nil } func (a *App) MoveNode(nodeID, newParentID string) error { node, err := a.nodes.GetActive(nodeID) if 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 // Update parent_id (use nil for root move) if newParentID == "" { if err := a.nodes.Move(nodeID, nil, 0); err != nil { return err } } else { if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil { return err } } if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil { return err } if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil { return err } if _, err := os.Stat(oldPhysPath); err == nil { if err := os.Rename(oldPhysPath, newPhysPath); err != nil { return fmt.Errorf("move folder: %w", err) } } } else { // Note/file node: update parent_id and move physical file if newParentID == "" { if err := a.nodes.Move(nodeID, nil, 0); err != nil { return err } } else { if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil { return err } } // Update file record path to reflect new parent if parent != nil && parent.FsPath != "" { rows, err := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID) if err == nil { defer rows.Close() for rows.Next() { var fileID, filePath string if err := rows.Scan(&fileID, &filePath); err != nil { continue } if filePath == "" { continue } filename := filepath.Base(filePath) newRelPath := filepath.Join(parent.FsPath, filename) oldAbs := filepath.Join(a.vault, filePath) 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, fileID) } } } } } } 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+`"}`) _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{ "parent_id": newParentID, "fs_path": node.FsPath, "updated_at": time.Now().UTC().Format(time.RFC3339), }) 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 } 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 }